diff --git a/apps/app/.ladle/story-fixtures.ts b/apps/app/.ladle/story-fixtures.ts index 2428c70e5f..eef540f6bb 100644 --- a/apps/app/.ladle/story-fixtures.ts +++ b/apps/app/.ladle/story-fixtures.ts @@ -13,6 +13,7 @@ import type { import type { ProjectResponse, SystemEnvironmentProvider, + SystemMachineProvider, } from "@bb/server-contract"; import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core"; import { @@ -339,6 +340,30 @@ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] = }, ]; +export const STORY_MACHINE_PROVIDERS: readonly SystemMachineProvider[] = [ + { + id: "modal-sandbox", + displayName: "Modal sandbox", + icon: "Box", + logoUrl: null, + pluginId: "environment-modal-sandbox", + requires: { gitRemote: true }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + environmentRow: { + displayName: "Modal sandbox", + environmentProviderId: "project-checkout", + }, + policy: { + idleSuspendMs: 15 * 60_000, + retire: { after: "last-thread", graceMs: 30 * 24 * 60 * 60_000 }, + removeRetryMs: 60_000, + }, + availability: null, + }, +]; + 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.test.tsx b/apps/app/src/components/dialogs/AddMachineDialog.test.tsx deleted file mode 100644 index 0c931e991f..0000000000 --- a/apps/app/src/components/dialogs/AddMachineDialog.test.tsx +++ /dev/null @@ -1,335 +0,0 @@ -// @vitest-environment jsdom - -import { - act, - cleanup, - fireEvent, - render, - screen, - waitFor, -} from "@testing-library/react"; -import type { Host } from "@bb/domain"; -import { makeHost as host } from "@bb/test-helpers/domain-fixtures"; -import type { InstalledPlugin } from "@bb/server-contract"; -import { MemoryRouter } from "react-router-dom"; -import { afterEach, describe, expect, it, vi } from "vitest"; -import { BbHttpError, sdk } from "@/lib/sdk"; -import { hostsQueryKey } from "@/hooks/queries/query-keys"; -import { 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() }, - }, - }; -}); - -vi.mock("@/lib/ws", () => ({ - wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() }, -})); - -const existingHost = host({ id: "host_primary", name: "MacBook Pro" }); - -function connectPlugin( - overrides: Pick, -): InstalledPlugin { - return makeInstalledPlugin({ - id: "connect", - source: "builtin:connect", - rootDir: "/plugins/connect", - provenance: "builtin", - publisherLabel: "BB Official", - sourceDisplay: "builtin · connect", - name: "Remote access", - hasSettings: true, - ...overrides, - }); -} - -function notRunningRpcError(status: string): BbHttpError { - const message = `plugin "connect" is not running (status: ${status})`; - return new BbHttpError({ - body: { ok: false, error: message }, - code: null, - message, - status: 503, - }); -} -const writeTextMock = vi.fn().mockResolvedValue(undefined); -Object.defineProperty(navigator, "clipboard", { - configurable: true, - value: { writeText: writeTextMock }, -}); - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -describe("AddMachineDialog", () => { - it("mints a join code, shows the pairing command, and detects the new machine connecting", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockResolvedValue({ - code: "mc_test456", - expiresAt: Date.now() + 10 * 60 * 1000, - serverUrl: "https://example.getbb.app", - }); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { queryClient, wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const command = await screen.findByText(/--join-code jc_test123/); - expect(sdk.plugins.callRpc).toHaveBeenCalledWith( - expect.objectContaining({ - pluginId: "connect", - method: "createMachineCode", - input: null, - }), - ); - expect(command.textContent).toContain("--host-id host_new"); - expect(command.textContent).toContain( - "curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 https://example.getbb.app/install.sh", - ); - expect(command.textContent).toContain("--server https://example.getbb.app"); - expect(command.textContent).toContain("--machine-code mc_test456"); - expect(command.textContent).not.toContain(window.location.origin); - expect(command.closest("[data-add-machine-command]")).not.toBeNull(); - expect( - screen.getByText( - /It installs bb and keeps the machine connected to this server/u, - ), - ).toBeDefined(); - expect(screen.getByText(/Code expires in \d+:\d{2}/)).toBeDefined(); - const waiting = screen.getByText("Waiting for the machine to connect…"); - expect(waiting).toBeDefined(); - expect(waiting.parentElement?.className).not.toContain("border-border"); - - fireEvent.click(screen.getByRole("button", { name: "Copy" })); - await waitFor(() => { - expect(writeTextMock).toHaveBeenCalledWith(command.textContent); - expect(screen.getByRole("button", { name: "Copied" })).toBeDefined(); - }); - - await waitFor(() => { - expect(queryClient.getQueryData(hostsQueryKey())).toHaveLength(1); - }); - - act(() => { - queryClient.setQueryData(hostsQueryKey(), [ - existingHost, - host({ id: "host_new", name: "Mac Studio" }), - ]); - }); - - expect(await screen.findByText("Mac Studio connected")).toBeDefined(); - expect( - screen.getByRole("button", { name: "Set up a project on it →" }), - ).toBeDefined(); - expect( - screen.queryByText("Waiting for the machine to connect…"), - ).toBeNull(); - }); - - it("falls back to direct pairing when connect is unpaired and ignores known hosts", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - new BbHttpError({ - body: { - ok: false, - error: { code: "handler_error", message: "not_paired" }, - }, - code: "handler_error", - message: "not_paired", - status: 500, - }), - ); - vi.mocked(sdk.hosts.list).mockResolvedValue([ - existingHost, - host({ id: "host_offline", name: "dev-vm", status: "disconnected" }), - ]); - - const { queryClient, wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const command = await screen.findByText(/--join-code jc_test123/); - expect(command.textContent).toContain( - "curl -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 http://direct.example.test:38886/install.sh", - ); - expect(command.textContent).toContain( - "--server http://direct.example.test:38886", - ); - expect(command.textContent).not.toContain("--machine-code"); - - await waitFor(() => { - expect(queryClient.getQueryData(hostsQueryKey())).toHaveLength(2); - }); - - act(() => { - queryClient.setQueryData(hostsQueryKey(), [ - existingHost, - host({ id: "host_offline", name: "dev-vm" }), - ]); - }); - - expect( - await screen.findByText("Waiting for the machine to connect…"), - ).toBeDefined(); - expect(screen.queryByText("dev-vm connected")).toBeNull(); - }); - - it("explains that a loopback server is unreachable when connect is unpaired", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - new BbHttpError({ - body: { - ok: false, - error: { code: "handler_error", message: "not_paired" }, - }, - code: "handler_error", - message: "not_paired", - status: 500, - }), - ); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - const notice = await screen.findByRole("status"); - expect(notice.textContent).toContain( - "Another machine cannot use this address.", - ); - expect(notice.textContent).toContain("http://127.0.0.1:38886"); - expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); - const link = screen.getByRole("link", { name: "Set up remote access" }); - expect(link.getAttribute("href")).toBe("/settings/plugins/connect"); - expect( - screen.queryByText("Waiting for the machine to connect…"), - ).toBeNull(); - }); - - it("offers a retry when connect is temporarily unavailable on a loopback server", async () => { - vi.mocked(sdk.hosts.createJoinCode).mockResolvedValue({ - joinCode: "jc_test123", - hostId: "host_new", - expiresAt: Date.now() + 15 * 60 * 1000, - }); - vi.mocked(sdk.plugins.callRpc).mockRejectedValue( - notRunningRpcError("degraded"), - ); - vi.mocked(sdk.plugins.list).mockResolvedValue({ - plugins: [connectPlugin({ enabled: true, status: "degraded" })], - }); - vi.mocked(sdk.hosts.list).mockResolvedValue([existingHost]); - - const { wrapper } = createQueryClientTestHarness(); - render( - - - , - { wrapper }, - ); - - expect( - await screen.findByText("Remote access isn't ready yet."), - ).toBeDefined(); - expect(screen.getByRole("button", { name: "Try again" })).toBeDefined(); - expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); - expect(screen.queryByRole("status")).toBeNull(); - }); - - 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", - ); - expect(screen.queryByText("Remote access isn't ready yet.")).toBeNull(); - expect(screen.queryByRole("button", { name: "Try again" })).toBeNull(); - expect( - screen.queryByText("Waiting for the machine to connect…"), - ).toBeNull(); - expect(screen.queryByText(/--join-code jc_test123/)).toBeNull(); - }); -}); diff --git a/apps/app/src/components/dialogs/AddMachineDialog.tsx b/apps/app/src/components/dialogs/AddMachineDialog.tsx deleted file mode 100644 index 585f44b8dd..0000000000 --- a/apps/app/src/components/dialogs/AddMachineDialog.tsx +++ /dev/null @@ -1,397 +0,0 @@ -import { useEffect, useRef, useState } from "react"; -import { Link } from "react-router-dom"; -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 { useClipboardCopy } from "@/lib/clipboard"; -import { isLocalOnlyUrl } from "@/lib/loopback-hostname"; -import { - getPluginConfigurationRoutePath, - getPluginDetailRoutePath, -} from "@/lib/route-paths"; -import { BbHttpError, sdk } from "@/lib/sdk"; -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; - } -} - -export function AddMachineDialog({ - open, - onOpenChange, - serverUrl, -}: AddMachineDialogProps) { - return ( - - - {open ? ( - - ) : 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")}`; -} - -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}`; -} - -const REMOTE_ACCESS_ROUTE = getPluginConfigurationRoutePath({ - pluginId: "connect", -}); -const CONNECT_PLUGIN_ROUTE = getPluginDetailRoutePath({ - pluginId: "connect", - view: "installed", -}); - -function UnreachableServerNotice({ - serverUrl, - reason, -}: { - serverUrl: string; - reason: "unpaired" | "disabled"; -}) { - 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 - -
-
- ); -} - -function AddMachineDialogContent({ - onOpenChange, - serverUrl, -}: { - onOpenChange: (open: boolean) => void; - serverUrl: string | null; -}) { - const hostsQuery = useHosts(); - const mintJoinCode = useMutation({ - meta: { showErrorToast: false }, - mutationFn: async () => { - const [join, machine] = await Promise.all([ - sdk.hosts.createJoinCode(), - createConnectMachineCode(), - ]); - return { join, machine }; - }, - }); - const mint = mintJoinCode.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; - - 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 ( - <> - - 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."} - - -
- {mintJoinCode.isError || connectUnavailable ? ( -
-

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

- -
- ) : 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… - - - )} -
- )} -
- - - - - ); -} 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/CreateMachineDialog.test.tsx b/apps/app/src/components/dialogs/CreateMachineDialog.test.tsx new file mode 100644 index 0000000000..cb10751c43 --- /dev/null +++ b/apps/app/src/components/dialogs/CreateMachineDialog.test.tsx @@ -0,0 +1,132 @@ +// @vitest-environment jsdom + +import { + cleanup, + fireEvent, + render, + screen, + waitFor, +} from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, expect, it, vi } from "vitest"; +import { sdk } from "@/lib/sdk"; +import { createQueryClientTestHarness } from "@/test/queryClientTestHarness"; +import { CreateMachineDialog } from "./CreateMachineDialog"; + +vi.mock("@/lib/sdk", async (importOriginal) => ({ + ...(await importOriginal()), + sdk: { + projects: { list: vi.fn().mockResolvedValue([]) }, + hosts: { + submit: vi.fn(), + experimental_enrollmentCommand: vi.fn().mockResolvedValue({ + command: "bb machine enroll --bootstrap-env BB_ENROLLMENT", + }), + follow: vi.fn(), + cancel: vi.fn(), + createJoinCode: vi.fn(), + list: vi.fn().mockResolvedValue([]), + listProviders: vi.fn(), + }, + }, +})); +vi.mock("@/lib/ws", () => ({ + wsManager: { subscribe: vi.fn(), unsubscribe: vi.fn() }, +})); +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +it("lists manual alongside other providers and never mints a legacy join code", async () => { + vi.mocked(sdk.hosts.listProviders).mockResolvedValue( + ["manual", "ssh", "modal", "digitalocean", "tailscale"].map((id) => ({ + id, + displayName: id === "manual" ? "Existing machine" : id, + icon: null, + logoUrl: null, + pluginId: `machine-${id}`, + requires: { gitRemote: false }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: false, + environmentRow: null, + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 60_000, + }, + availability: { status: "available" }, + })), + ); + vi.mocked(sdk.projects.list).mockResolvedValue([ + { + id: "project-fixture", + kind: "standard", + name: "Fixture", + gitRemoteUrl: null, + createdAt: 1, + updatedAt: 1, + sources: [], + }, + ]); + const launch = { + id: "launch-manual", + machineProviderId: "manual", + projectId: null, + hostId: null, + phase: "creating" as const, + step: "Run the enrollment command shown in the picker", + message: null, + log: "", + cancelPending: false, + terminal: false, + }; + vi.mocked(sdk.hosts.submit).mockResolvedValue(launch); + vi.mocked(sdk.hosts.follow).mockImplementation(async (args) => { + args.onProgress?.(launch); + return new Promise(() => {}); + }); + vi.mocked(sdk.hosts.cancel).mockResolvedValue({ + ...launch, + phase: "cancelled", + }); + const { wrapper } = createQueryClientTestHarness(); + render( + + {}} /> + , + { wrapper }, + ); + fireEvent.click( + await screen.findByRole("button", { name: "Existing machine" }), + ); + for (const name of ["ssh", "modal", "digitalocean", "tailscale"]) + expect(screen.getByRole("button", { name })).toBeDefined(); + await screen.findByRole("option", { name: "Fixture" }); + fireEvent.change(screen.getByRole("combobox", { name: "Machine project" }), { + target: { value: "project-fixture" }, + }); + fireEvent.click( + screen.getByRole("button", { name: "Create Existing machine" }), + ); + expect((await screen.findByRole("status")).textContent).toContain( + "Run the enrollment command shown in the picker", + ); + expect( + await screen.findByText("bb machine enroll --bootstrap-env BB_ENROLLMENT"), + ).toBeTruthy(); + expect(sdk.hosts.experimental_enrollmentCommand).toHaveBeenCalledWith({ + id: "launch-manual", + scope: "launch", + signal: expect.any(AbortSignal), + }); + fireEvent.click(screen.getByRole("button", { name: "Cancel enrollment" })); + await waitFor(() => + expect(sdk.hosts.cancel).toHaveBeenCalledWith({ id: "launch-manual" }), + ); + expect(sdk.hosts.submit).toHaveBeenCalledWith( + expect.objectContaining({ projectId: "project-fixture" }), + ); + expect(sdk.hosts.createJoinCode).not.toHaveBeenCalled(); +}); diff --git a/apps/app/src/components/dialogs/CreateMachineDialog.tsx b/apps/app/src/components/dialogs/CreateMachineDialog.tsx new file mode 100644 index 0000000000..fd216b6bfa --- /dev/null +++ b/apps/app/src/components/dialogs/CreateMachineDialog.tsx @@ -0,0 +1,324 @@ +import { MachineEnrollmentCommand } from "./MachineEnrollmentCommand"; +import { useEffect, useRef, useState } from "react"; +import { Link } from "react-router-dom"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import type { JsonValue } from "@bb/domain"; +import type { PluginMachineProviderInputsChange } from "@get-bb/plugin-sdk"; +import type { SystemMachineProvider } from "@bb/server-contract"; +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 { cn } from "@bb/shared-ui/lib/utils"; +import { MachineProviderIcon } from "@/components/plugin/MachineProviderIcon"; +import { PluginSlotMount } from "@/components/plugin/PluginSlotMount"; +import { machineProviderInputsControlRequired } from "@/components/pickers/machine-provider-inputs"; +import { useHosts } from "@/hooks/queries/host-queries"; +import { useSystemMachineProviders } from "@/hooks/queries/machine-provider-queries"; +import { getPluginConfigurationRoutePath } from "@/lib/route-paths"; +import { sdk } from "@/lib/sdk"; +import { getMutationErrorMessage } from "@/lib/mutation-errors"; +import { usePluginSlots } from "@/lib/plugin-slots"; + +export function CreateMachineDialog({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + return ( + + + + + + ); +} + +function CreateMachineContent({ + open, + onOpenChange, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; +}) { + const createController = useRef(null); + const createKey = useRef(null); + const [progress, setProgress] = useState(""); + const [launchId, setLaunchId] = useState(null); + useEffect(() => { + if (!open) { + createController.current?.abort(); + createKey.current = null; + } + }, [open]); + useEffect(() => () => createController.current?.abort(), []); + const hostsQuery = useHosts(); + const projects = useQuery({ + queryKey: ["machine-create-projects"], + queryFn: () => sdk.projects.list(), + enabled: open, + }); + const [projectId, setProjectId] = useState(null); + const { providers: machineProviders } = useSystemMachineProviders(); + const machineProviderInputsSlots = usePluginSlots().machineProviderInputs; + const [selectedMachineProvider, setSelectedMachineProvider] = + useState(null); + const [machineInputs, setMachineInputs] = useState(null); + const [machineInputsBlocked, setMachineInputsBlocked] = useState< + string | null + >(null); + const machineInputsRegistration = + selectedMachineProvider === null + ? undefined + : machineProviderInputsSlots.find( + (slot) => + slot.machineProviderId === selectedMachineProvider.id && + slot.pluginId === selectedMachineProvider.pluginId, + ); + const MachineInputsComponent = machineInputsRegistration?.component; + const selectMachineProvider = (provider: SystemMachineProvider): void => { + createKey.current = null; + setSelectedMachineProvider(provider); + setMachineInputs( + provider.inputs === null ? null : provider.acceptsEmptyInputs ? {} : null, + ); + setMachineInputsBlocked(null); + }; + const handleMachineInputsChange = ( + next: PluginMachineProviderInputsChange, + ): void => { + createKey.current = null; + if (next.status === "blocked") { + setMachineInputsBlocked(next.reason); + return; + } + setMachineInputsBlocked(null); + setMachineInputs(next.value); + }; + const createMachine = useMutation({ + meta: { showErrorToast: false }, + mutationFn: async () => { + if (selectedMachineProvider === null) { + throw new Error("Select a machine provider."); + } + setProgress(""); + setLaunchId(null); + const controller = new AbortController(); + createController.current = controller; + createKey.current ??= crypto.randomUUID(); + try { + const launch = await sdk.hosts.submit({ + key: createKey.current, + machineProviderId: selectedMachineProvider.id, + projectId, + inputs: machineInputs, + signal: controller.signal, + }); + setLaunchId(launch.id); + return await sdk.hosts.follow({ + id: launch.id, + signal: controller.signal, + onProgress: (status) => setProgress(status.step), + }); + } finally { + if (createController.current === controller) + createController.current = null; + } + }, + onSuccess: async () => { + await hostsQuery.refetch(); + onOpenChange(false); + }, + }); + + return ( + <> + + Add a machine + Choose how to add your machine. + +
+ {(machineProviders?.length ?? 0) > 0 ? ( +
+
+ {machineProviders?.map((provider) => { + const unavailable = + provider.availability?.status === "unavailable"; + return ( + + ); + })} +
+ {selectedMachineProvider === null ? null : ( +
+ + {projects.error && ( +

+ Could not load projects: {projects.error.message} +

+ )} + + {machineInputsRegistration === undefined || + MachineInputsComponent === undefined ? null : ( + + + + )} + {selectedMachineProvider.availability?.status === + "setup-required" ? ( + + ) : ( + + )} + {machineInputsBlocked === null ? null : ( +

+ {machineInputsBlocked} +

+ )} + {createMachine.isError ? ( +

+ {getMutationErrorMessage({ + error: createMachine.error, + fallbackMessage: "Couldn't create the machine.", + })} +

+ ) : null} +
+ )} +
+ ) : null} + + {createMachine.isPending && progress ? ( +
+
+              {progress}
+            
+
+ ) : null} +
+ {open && createMachine.isPending && launchId ? ( + + ) : null} + + {createMachine.isPending && launchId ? ( + + ) : null} + + + + ); +} diff --git a/apps/app/src/components/dialogs/MachineEnrollmentCommand.test.tsx b/apps/app/src/components/dialogs/MachineEnrollmentCommand.test.tsx new file mode 100644 index 0000000000..9426f1565d --- /dev/null +++ b/apps/app/src/components/dialogs/MachineEnrollmentCommand.test.tsx @@ -0,0 +1,50 @@ +// @vitest-environment jsdom + +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { sdk } from "@/lib/sdk"; +import { MachineEnrollmentCommand } from "./MachineEnrollmentCommand"; + +vi.mock("@/lib/sdk", () => ({ + sdk: { hosts: { experimental_enrollmentCommand: vi.fn() } }, +})); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +it("discards the private command when the server settles enrollment", async () => { + vi.mocked(sdk.hosts.experimental_enrollmentCommand) + .mockResolvedValueOnce({ + command: "bb machine enroll --bootstrap-env PRIVATE_BUNDLE", + }) + .mockResolvedValue({ command: null }); + render(); + expect( + await screen.findByText("bb machine enroll --bootstrap-env PRIVATE_BUNDLE"), + ).toBeTruthy(); + await waitFor( + () => + expect( + screen.queryByText("bb machine enroll --bootstrap-env PRIVATE_BUNDLE"), + ).toBeNull(), + { timeout: 3000 }, + ); + expect(screen.queryByRole("button", { name: "Copy command" })).toBeNull(); +}); + +it("aborts retrieval when the follower closes", async () => { + let signal: AbortSignal | undefined; + vi.mocked(sdk.hosts.experimental_enrollmentCommand).mockImplementation( + async (args) => { + signal = args.signal; + return new Promise(() => {}); + }, + ); + const view = render( + , + ); + expect(signal?.aborted).toBe(false); + view.unmount(); + expect(signal?.aborted).toBe(true); +}); diff --git a/apps/app/src/components/dialogs/MachineEnrollmentCommand.tsx b/apps/app/src/components/dialogs/MachineEnrollmentCommand.tsx new file mode 100644 index 0000000000..09fbd1507f --- /dev/null +++ b/apps/app/src/components/dialogs/MachineEnrollmentCommand.tsx @@ -0,0 +1,53 @@ +import { useEffect, useState } from "react"; +import { Button } from "@bb/shared-ui/button"; +import { sdk } from "@/lib/sdk"; + +export function MachineEnrollmentCommand({ + id, + scope, +}: { + id: string; + scope: "launch" | "thread"; +}) { + const [command, setCommand] = useState(null); + useEffect(() => { + const controller = new AbortController(); + let timer: ReturnType; + setCommand(null); + const poll = async () => { + try { + const result = await sdk.hosts.experimental_enrollmentCommand({ + id, + scope, + signal: controller.signal, + }); + if (!controller.signal.aborted) setCommand(result.command); + } catch { + if (!controller.signal.aborted) setCommand(null); + } + if (!controller.signal.aborted) + timer = setTimeout(() => void poll(), 1000); + }; + void poll(); + return () => { + controller.abort(); + clearTimeout(timer); + }; + }, [id, scope]); + if (command === null) return null; + return ( +
+

Run on the target machine:

+
+        {command}
+      
+ +
+ ); +} diff --git a/apps/app/src/components/dialogs/ProjectPathDialog.tsx b/apps/app/src/components/dialogs/ProjectPathDialog.tsx index 2658126dc2..61fddb4aff 100644 --- a/apps/app/src/components/dialogs/ProjectPathDialog.tsx +++ b/apps/app/src/components/dialogs/ProjectPathDialog.tsx @@ -1,4 +1,3 @@ -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { useEffect, useId, useState, type FormEvent } from "react"; import { DropdownMenu, @@ -26,8 +25,9 @@ import { import { Input } from "@bb/shared-ui/input"; import { cn } from "@bb/shared-ui/lib/utils"; import { RemotePathBrowser } from "@/components/dialogs/RemotePathBrowser"; +import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { usePointerCoarse } from "@bb/shared-ui/hooks/use-pointer-coarse"; -import { selectPersistentHosts } from "@/hooks/queries/host-queries"; +import { selectHosts } from "@/hooks/queries/host-queries"; export type ProjectPathDialogTarget = | { @@ -159,7 +159,7 @@ export function ProjectPathDialogContent({ const inputId = useId(); const isPointerCoarse = usePointerCoarse(); const machineOptions = - target.kind === "create" ? selectPersistentHosts(hosts) : undefined; + target.kind === "create" ? selectHosts(hosts) : undefined; const firstConnectedHostId = machineOptions?.find( (host) => host.status === "connected", )?.id; diff --git a/apps/app/src/components/machines/MachineLifecycleNotice.test.tsx b/apps/app/src/components/machines/MachineLifecycleNotice.test.tsx new file mode 100644 index 0000000000..d7cf9b348a --- /dev/null +++ b/apps/app/src/components/machines/MachineLifecycleNotice.test.tsx @@ -0,0 +1,85 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, render, waitFor } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, expect, it, vi } from "vitest"; +import { MachineLifecycleNotice } from "./MachineLifecycleNotice"; +import { sdk } from "@/lib/sdk"; + +vi.mock("@/lib/sdk", () => ({ + sdk: { hosts: { experimental_lifecycle: vi.fn() } }, +})); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +it("keeps retention explicitly and delegates removal through the existing confirmation flow", async () => { + let kept = false; + vi.mocked(sdk.hosts.experimental_lifecycle).mockImplementation( + async (args) => { + if (args.keep !== undefined) kept = args.keep; + return { + phase: "suspended", + expiresAt: null, + maintenanceAt: null, + lastSnapshotAt: 1, + recoveryState: "healthy", + message: null, + retentionAt: Date.now() + 60_000, + keep: kept, + }; + }, + ); + const remove = vi.fn(); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const view = render( + + + , + ); + fireEvent.click(await view.findByText("Keep machine")); + await view.findByText("Allow automatic deletion"); + expect(sdk.hosts.experimental_lifecycle).toHaveBeenCalledWith({ + hostId: "machine", + keep: true, + }); + fireEvent.click(view.getByText("Remove machine")); + expect(remove).toHaveBeenCalledTimes(1); + fireEvent.click(view.getByText("Allow automatic deletion")); + await waitFor(() => expect(kept).toBe(false)); + client.clear(); +}); + +it.each(["Compute disappeared before preservation completed", null])( + "shows preservation loss without lifecycle dates: %s", + async (message) => { + vi.mocked(sdk.hosts.experimental_lifecycle).mockResolvedValue({ + phase: "active", + expiresAt: null, + maintenanceAt: null, + lastSnapshotAt: null, + recoveryState: "lost-since-last-snapshot", + message, + retentionAt: null, + keep: true, + }); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const view = render( + + {}} /> + , + ); + expect( + await view.findByText( + message ?? + "Machine preservation was lost. Explicit recovery is required.", + ), + ).toBeTruthy(); + expect(view.getByText("Remove machine")).toBeTruthy(); + client.clear(); + }, +); diff --git a/apps/app/src/components/machines/MachineLifecycleNotice.tsx b/apps/app/src/components/machines/MachineLifecycleNotice.tsx new file mode 100644 index 0000000000..d987bf6bbf --- /dev/null +++ b/apps/app/src/components/machines/MachineLifecycleNotice.tsx @@ -0,0 +1,87 @@ +import { useMutation, useQuery } from "@tanstack/react-query"; +import { Button } from "@bb/shared-ui/button"; +import { sdk } from "@/lib/sdk"; + +export function MachineLifecycleNotice({ + hostId, + onRemove, +}: { + hostId: string; + onRemove: () => void; +}) { + const query = useQuery({ + queryKey: ["machine-lifecycle", hostId], + queryFn: () => sdk.hosts.experimental_lifecycle({ hostId }), + refetchInterval: 10_000, + }); + const keep = useMutation({ + mutationFn: (value: boolean) => + sdk.hosts.experimental_lifecycle({ hostId, keep: value }), + onSuccess: () => query.refetch(), + }); + const lifecycle = query.data; + if ( + !lifecycle || + (lifecycle.expiresAt === null && + lifecycle.retentionAt === null && + lifecycle.lastSnapshotAt === null && + lifecycle.message === null && + lifecycle.recoveryState !== "recoverable" && + lifecycle.recoveryState !== "lost-since-last-snapshot") + ) + return null; + const approaching = + lifecycle.maintenanceAt !== null && + lifecycle.maintenanceAt <= Date.now() + 15 * 60_000; + return ( +
+ {approaching && ( +

+ Preservation scheduled for{" "} + {new Date(lifecycle.maintenanceAt ?? 0).toLocaleString()}. Active + turns will be interrupted and terminals closed. +

+ )} + {lifecycle.message &&

{lifecycle.message}

} + {!lifecycle.message && + (lifecycle.recoveryState === "recoverable" || + lifecycle.recoveryState === "lost-since-last-snapshot") && ( +

+ {lifecycle.recoveryState === "lost-since-last-snapshot" + ? "Machine preservation was lost. Explicit recovery is required." + : "Machine preservation failed. Recovery is required."} +

+ )} + {lifecycle.lastSnapshotAt !== null && ( +

Last saved {new Date(lifecycle.lastSnapshotAt).toLocaleString()}.

+ )} + {lifecycle.retentionAt !== null && ( +

+ {lifecycle.keep + ? "Automatic deletion disabled. Retention date:" + : "Automatically deletes after retention:"}{" "} + {new Date(lifecycle.retentionAt).toLocaleString()}. +

+ )} +
+ + +
+ {keep.error && ( +

Could not update retention: {keep.error.message}

+ )} +
+ ); +} diff --git a/apps/app/src/components/machines/MachinePhaseBadge.test.ts b/apps/app/src/components/machines/MachinePhaseBadge.test.ts new file mode 100644 index 0000000000..16130d8fb3 --- /dev/null +++ b/apps/app/src/components/machines/MachinePhaseBadge.test.ts @@ -0,0 +1,33 @@ +import type { MachineLifecycle } from "@bb/domain"; +import { describe, expect, it } from "vitest"; +import { machinePhaseLabel } from "./MachinePhaseBadge"; + +function lifecycle( + phase: MachineLifecycle["phase"], + teardown: MachineLifecycle["teardown"] = null, +): MachineLifecycle { + return { phase, suspendedAt: null, retireAt: null, progress: null, teardown }; +} + +describe("machinePhaseLabel", () => { + it.each([ + ["active", null], + ["destroyed", null], + ["suspended", "Suspended"], + ["retiring", "Retiring"], + ] as const)("maps %s to %s", (phase, label) => { + expect(machinePhaseLabel(lifecycle(phase))).toBe(label); + }); + + it("prioritizes cleanup failure over retiring", () => { + expect( + machinePhaseLabel( + lifecycle("retiring", { + status: "failed", + attempt: 1, + message: "uninstall failed", + }), + ), + ).toBe("Cleanup failed"); + }); +}); diff --git a/apps/app/src/components/machines/MachinePhaseBadge.tsx b/apps/app/src/components/machines/MachinePhaseBadge.tsx new file mode 100644 index 0000000000..ebd6395ef7 --- /dev/null +++ b/apps/app/src/components/machines/MachinePhaseBadge.tsx @@ -0,0 +1,40 @@ +import type { MachineLifecycle } from "@bb/domain"; +import { cn } from "@bb/shared-ui/lib/utils"; + +interface MachinePhaseBadgeProps { + lifecycle: MachineLifecycle; +} + +export function machinePhaseLabel( + lifecycle: MachineLifecycle, +): "Suspended" | "Retiring" | "Cleanup failed" | null { + if ( + lifecycle.phase === "retiring" && + lifecycle.teardown?.status === "failed" + ) { + return "Cleanup failed"; + } + if (lifecycle.phase === "suspended") return "Suspended"; + if (lifecycle.phase === "retiring") return "Retiring"; + return null; +} + +export function MachinePhaseBadge({ lifecycle }: MachinePhaseBadgeProps) { + const label = machinePhaseLabel(lifecycle); + if (label === null) return null; + return ( + + {label} + + ); +} diff --git a/apps/app/src/components/machines/MachineProviderDetails.tsx b/apps/app/src/components/machines/MachineProviderDetails.tsx new file mode 100644 index 0000000000..34f496fc1e --- /dev/null +++ b/apps/app/src/components/machines/MachineProviderDetails.tsx @@ -0,0 +1,32 @@ +import { useQuery } from "@tanstack/react-query"; +import { sdk } from "@/lib/sdk"; + +export function MachineProviderDetails({ + hostId, + expanded = false, +}: { + hostId: string; + expanded?: boolean; +}) { + const query = useQuery({ + queryKey: ["machine-provider-details", hostId], + queryFn: ({ signal }) => + sdk.hosts.experimental_providerDetails({ hostId, signal }), + staleTime: 60_000, + refetchInterval: 60_000, + }); + if (query.error) + return ( +

+ Provider inventory unavailable +

+ ); + if (!query.data) return null; + return ( +
+

+ {query.data.summary} +

+
+ ); +} diff --git a/apps/app/src/components/pickers/EnvironmentPicker.test.tsx b/apps/app/src/components/pickers/EnvironmentPicker.test.tsx index 2c9852970f..5f35bc5dad 100644 --- a/apps/app/src/components/pickers/EnvironmentPicker.test.tsx +++ b/apps/app/src/components/pickers/EnvironmentPicker.test.tsx @@ -4,7 +4,10 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; 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"; -import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import type { + SystemEnvironmentProvider, + SystemMachineProvider, +} from "@bb/server-contract"; import { afterEach, describe, expect, it, vi } from "vitest"; import { EnvironmentPickerUI, @@ -86,6 +89,28 @@ const optionalInputsProvider: SystemEnvironmentProvider = { }, }; +const modalMachineProvider: SystemMachineProvider = { + id: "modal-sandbox", + displayName: "Modal sandbox", + icon: "Box", + logoUrl: null, + pluginId: "environment-modal-sandbox", + requires: { gitRemote: true }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + environmentRow: { + displayName: "Modal sandbox", + environmentProviderId: checkoutProvider.id, + }, + policy: { + idleSuspendMs: 60_000, + retire: { after: "last-thread", graceMs: 60_000 }, + removeRetryMs: 60_000, + }, + availability: null, +}; + const host = makeHost({ id: "host_test", name: "Local host", @@ -325,6 +350,85 @@ describe("EnvironmentPickerUI", () => { host.id, ); }); + + it("offers Existing machine alongside opted-in shortcuts without a DigitalOcean shortcut", () => { + const providers = [ + "manual", + "ssh", + "modal", + "digitalocean", + "tailscale", + ].map((id) => ({ + ...modalMachineProvider, + id, + displayName: id === "manual" ? "Existing machine" : id, + requires: { gitRemote: false }, + supportsSuspend: false, + environmentRow: + id === "manual" || id === "digitalocean" + ? null + : { displayName: id, environmentProviderId: "project-checkout" }, + })); + const onSelect = vi.fn(); + render( + , + ); + fireEvent.pointerDown(screen.getByRole("button", { name: "Environment" }), { + button: 0, + }); + for (const name of ["Existing machine", "ssh", "modal", "tailscale"]) + expect( + screen.getByRole("menuitem", { name: new RegExp(name, "u") }), + ).toBeDefined(); + expect( + screen.queryByRole("menuitem", { name: /digitalocean/u }), + ).toBeNull(); + fireEvent.click( + screen.getByRole("menuitem", { name: /Existing machine/u }), + ); + expect(onSelect).toHaveBeenCalledWith(providers[0]); + }); + + it("puts machine-provider rows last after a separator", () => { + render( + , + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Environment" }), { + button: 0, + }); + + const menuItems = screen.getAllByRole("menuitem"); + const modalItem = screen.getByRole("menuitem", { + name: /Modal sandbox/u, + }); + const modalGroup = modalItem.closest('[role="group"]'); + const separator = screen.getByRole("separator"); + expect(menuItems.at(-1)).toBe(modalItem); + expect(modalGroup?.previousElementSibling).toBe(separator); + }); }); describe("EnvironmentPickerUI multi-machine menu", () => { @@ -402,6 +506,38 @@ describe("EnvironmentPickerUI multi-machine menu", () => { expect(onSelectProvider).toHaveBeenCalledWith(checkoutProvider, studio.id); }); + it("includes provider-made hosts in environment picker machine sections", () => { + const providerHost = makeHost({ + id: "host_modal", + name: "Modal sandbox 3f9a", + machineProviderId: "modal-sandbox", + }); + render( + , + ); + + fireEvent.pointerDown(screen.getByRole("button", { name: "Environment" }), { + button: 0, + }); + + expect(screen.getByText("Mac Studio")).toBeTruthy(); + expect(screen.getByText("Modal sandbox 3f9a")).toBeTruthy(); + }); + it("does not show project checkout paths in machine headers", () => { renderMachineMenu(); diff --git a/apps/app/src/components/pickers/EnvironmentPicker.tsx b/apps/app/src/components/pickers/EnvironmentPicker.tsx index 13315ec43a..b5f4173dfc 100644 --- a/apps/app/src/components/pickers/EnvironmentPicker.tsx +++ b/apps/app/src/components/pickers/EnvironmentPicker.tsx @@ -1,7 +1,11 @@ import { EnvironmentProviderIcon } from "@/components/plugin/EnvironmentProviderIcon"; +import { MachineProviderIcon } from "@/components/plugin/MachineProviderIcon"; import { useMemo } from "react"; import type { Host, ProjectSource } from "@bb/domain"; -import type { SystemEnvironmentProvider } from "@bb/server-contract"; +import type { + SystemEnvironmentProvider, + SystemMachineProvider, +} from "@bb/server-contract"; import { Icon, type IconName } from "@bb/shared-ui/icon"; import { findLocalPathProjectSourceForHost } from "@bb/domain"; import { pluginIconName } from "@/components/plugin/PluginIcon"; @@ -12,6 +16,7 @@ import { DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@bb/shared-ui/dropdown-menu"; import { @@ -36,8 +41,9 @@ import { encodeProviderValue, parseEnvironmentValue, } from "./environment-picker-value"; -import { selectPersistentHosts } from "@/hooks/queries/host-queries"; +import { selectHosts } from "@/hooks/queries/host-queries"; import { providerInputsControlRequired } from "./environment-provider-inputs"; +import { machineProviderInputsControlRequired } from "./machine-provider-inputs"; interface SelectedEnvironment { modeLabel: string; @@ -75,6 +81,10 @@ export interface EnvironmentPickerUIProps { provider: SystemEnvironmentProvider, hostId: string | null, ) => void; + machineProviders?: readonly SystemMachineProvider[]; + selectedMachineProviderId?: string | null; + machineInputsControlProviderIds?: ReadonlySet; + onSelectMachineProvider?: (provider: SystemMachineProvider) => void; } export const PROVIDER_INPUTS_CONTROL_MISSING_REASON = @@ -82,6 +92,22 @@ export const PROVIDER_INPUTS_CONTROL_MISSING_REASON = const NO_INPUTS_CONTROL_PROVIDER_IDS: ReadonlySet = new Set(); +function machineProviderDisabledReason( + provider: SystemMachineProvider, + inputsControlProviderIds: ReadonlySet, +): string | null { + if (provider.availability?.status === "unavailable") { + return provider.availability.message; + } + if ( + !inputsControlProviderIds.has(provider.id) && + machineProviderInputsControlRequired(provider) + ) { + return PROVIDER_INPUTS_CONTROL_MISSING_REASON; + } + return null; +} + function providerValueSelected( value: string, provider: SystemEnvironmentProvider, @@ -144,7 +170,7 @@ export function EnvironmentPickerUI({ disabled = false, className, defaultOpen, - modal, + modal = false, machines, onRequestMachineSetup, providers = [], @@ -153,12 +179,16 @@ export function EnvironmentPickerUI({ selectedProviderHostId = null, inputsControlProviderIds = NO_INPUTS_CONTROL_PROVIDER_IDS, onSelectProvider, + machineProviders: creatableMachineProviders = [], + selectedMachineProviderId = null, + machineInputsControlProviderIds = NO_INPUTS_CONTROL_PROVIDER_IDS, + onSelectMachineProvider, }: EnvironmentPickerUIProps) { const availableMachines = useMemo( () => machines === null || machines === undefined ? null - : { ...machines, hosts: selectPersistentHosts(machines.hosts) }, + : { ...machines, hosts: selectHosts(machines.hosts) }, [machines], ); const hostId = host?.id ?? null; @@ -200,7 +230,28 @@ export function EnvironmentPickerUI({ : undefined, [environmentProviders, parsed], ); + const selectedMachineProvider = useMemo( + () => + selectedMachineProviderId === null + ? undefined + : creatableMachineProviders.find( + (provider) => provider.id === selectedMachineProviderId, + ), + [creatableMachineProviders, selectedMachineProviderId], + ); + const selected = useMemo((): SelectedEnvironment => { + if (selectedMachineProvider !== undefined) { + return { + modeLabel: + selectedMachineProvider.environmentRow?.displayName ?? + selectedMachineProvider.displayName, + compactModeLabel: + selectedMachineProvider.environmentRow?.displayName ?? + selectedMachineProvider.displayName, + icon: pluginIconName(selectedMachineProvider.icon), + }; + } if (selectedProvider !== undefined && hostUnavailableReason === null) { const showsHost = selectedMachineName !== null; return { @@ -238,6 +289,7 @@ export function EnvironmentPickerUI({ host, selectedMachineName, selectedProvider, + selectedMachineProvider, ]); return ( @@ -260,7 +312,12 @@ export function EnvironmentPickerUI({ )} > - {selectedProvider === undefined ? ( + {selectedMachineProvider !== undefined ? ( + + ) : selectedProvider === undefined ? ( )} + {onSelectMachineProvider === undefined ? null : ( + + )} ); } +function MachineProviderEnvironmentOptions({ + providers, + selectedProviderId, + inputsControlProviderIds, + onSelect, +}: { + providers: readonly SystemMachineProvider[]; + selectedProviderId: string | null; + inputsControlProviderIds: ReadonlySet; + onSelect: (provider: SystemMachineProvider) => void; +}) { + const rows = providers.filter( + (provider) => provider.environmentRow !== null || provider.id === "manual", + ); + if (rows.length === 0) return null; + return ( + <> + + + New machine + {rows.map((provider) => { + const disabledReason = machineProviderDisabledReason( + provider, + inputsControlProviderIds, + ); + const description = + provider.availability?.status === "setup-required" + ? provider.availability.message + : (disabledReason ?? undefined); + return ( + onSelect(provider)} + /> + ); + })} + + + ); +} + interface EnvironmentOptionsSectionProps { hostId: string | null; hostName: string | null; @@ -488,12 +601,24 @@ function MachineSection({ }: MachineSectionProps) { const connected = host.status === "connected"; const hostProviders = machineProviders; + const selectable = + connected || + (host.machineProviderId !== null && host.lifecycle.phase === "suspended"); return ( {host.name} + {host.machineProviderId ? ( + + {host.lifecycle.phase === "suspended" + ? "paused" + : host.lifecycle.phase === "active" && connected + ? "running" + : host.lifecycle.phase} + + ) : null} {isThisMachine ? ( this machine ) : null} @@ -530,7 +655,7 @@ function MachineSection({ providerValueSelected(value, provider) && selectedProviderHostId === host.id } - disabled={!connected || disabledReason !== null} + disabled={!selectable || disabledReason !== null} onSelect={() => onSelectProvider(provider, host.id)} /> ); @@ -625,3 +750,58 @@ function EnvironmentMenuItem({ ); } + +function MachineProviderMenuItem({ + provider, + label, + description, + selected, + onSelect, + disabled, +}: { + provider: SystemMachineProvider; + label: string; + description?: string; + selected: boolean; + onSelect: () => void; + disabled: boolean; +}) { + return ( + { + if (!disabled) onSelect(); + }} + className={cn( + "flex items-start justify-between gap-3 whitespace-normal", + LIST_HOVER_TRANSITION, + )} + > + + + + {label} + {description === undefined ? null : ( + + {description} + + )} + + + + + ); +} diff --git a/apps/app/src/components/pickers/MachinePicker.test.tsx b/apps/app/src/components/pickers/MachinePicker.test.tsx index 4b2a02e520..58cdeac305 100644 --- a/apps/app/src/components/pickers/MachinePicker.test.tsx +++ b/apps/app/src/components/pickers/MachinePicker.test.tsx @@ -100,4 +100,20 @@ describe("MachinePickerUI", () => { screen.getByRole("button", { name: "Machine" }).textContent, ).toContain("MacBook Pro"); }); + + it("includes provider-made hosts in machine pickers", () => { + renderMachineMenu({ + hosts: [ + thisMachine, + studio, + makeHost({ + id: "host_modal", + name: "Modal sandbox 3f9a", + machineProviderId: "modal-sandbox", + }), + ], + }); + + expect(screen.getByText("Modal sandbox 3f9a")).toBeTruthy(); + }); }); diff --git a/apps/app/src/components/pickers/MachinePicker.tsx b/apps/app/src/components/pickers/MachinePicker.tsx index d7843a3932..90cbef5cba 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,10 +14,8 @@ 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"; @@ -56,7 +53,7 @@ export function MachinePickerUI({ className, modal, }: MachinePickerUIProps) { - const availableHosts = useMemo(() => selectPersistentHosts(hosts), [hosts]); + const availableHosts = useMemo(() => selectHosts(hosts), [hosts]); const selectedHost = useMemo( () => availableHosts.find((host) => host.id === selectedHostId) ?? diff --git a/apps/app/src/components/pickers/machine-provider-inputs.ts b/apps/app/src/components/pickers/machine-provider-inputs.ts new file mode 100644 index 0000000000..c3630195cc --- /dev/null +++ b/apps/app/src/components/pickers/machine-provider-inputs.ts @@ -0,0 +1,7 @@ +import type { SystemMachineProvider } from "@bb/server-contract"; + +export function machineProviderInputsControlRequired( + provider: SystemMachineProvider, +): boolean { + return provider.inputs !== null && !provider.acceptsEmptyInputs; +} diff --git a/apps/app/src/components/plugin/MachineProviderIcon.tsx b/apps/app/src/components/plugin/MachineProviderIcon.tsx new file mode 100644 index 0000000000..7ad98c5214 --- /dev/null +++ b/apps/app/src/components/plugin/MachineProviderIcon.tsx @@ -0,0 +1,25 @@ +import type { SystemMachineProvider } from "@bb/server-contract"; +import { Icon } from "@bb/shared-ui/icon"; +import { getProviderIconInfo } from "@/lib/provider-icon"; +import { pluginIconName } from "./PluginIcon"; + +export function MachineProviderIcon({ + provider, + className, +}: { + provider: SystemMachineProvider; + className?: string; +}) { + if (provider.icon === null) return null; + const info = getProviderIconInfo(provider.id, { + logoUrl: provider.logoUrl, + displayName: provider.displayName, + icon: { glyph: provider.icon }, + }); + const ProviderIcon = info?.icon; + return ProviderIcon === undefined ? ( + + ) : ( + + ); +} diff --git a/apps/app/src/components/plugin/PluginBranchPicker.tsx b/apps/app/src/components/plugin/PluginBranchPicker.tsx index 9844571c3d..1cd074e98a 100644 --- a/apps/app/src/components/plugin/PluginBranchPicker.tsx +++ b/apps/app/src/components/plugin/PluginBranchPicker.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import type { BranchPickerProps } from "@get-bb/plugin-sdk"; +import type { ExperimentalBranchPickerProps } from "@get-bb/plugin-sdk"; import { BranchPicker } from "@/components/pickers/BranchPicker"; import { usePluginBranches, @@ -14,7 +14,7 @@ export function PluginBranchPicker({ label, placeholder, disabled, -}: BranchPickerProps) { +}: ExperimentalBranchPickerProps) { const [searchQuery, setSearchQuery] = useState(""); const { branches, remoteBranches, isLoading, refresh } = usePluginBranches({ hostId, diff --git a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx index 7afb1fbc7a..a19cc9078b 100644 --- a/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx +++ b/apps/app/src/components/plugin/PluginNewThreadComposer.test.tsx @@ -30,6 +30,7 @@ import type { import type { SystemEnvironmentProvider } from "@bb/server-contract"; import { NewThreadComposer, + resolveSubmittedExecutionSources, type NewThreadComposerState, } from "@/components/promptbox/NewThreadComposer"; import { @@ -81,6 +82,10 @@ vi.mock("@/hooks/queries/environment-provider-queries", () => ({ ) => new Map(hostIds.map((hostId) => [hostId, mocks.environmentProviders])), })); +vi.mock("@/hooks/queries/machine-provider-queries", () => ({ + useSystemMachineProviders: () => ({ providers: [] }), +})); + vi.mock("@/lib/sdk", () => ({ sdk: { projects: { attachments: { copy: mocks.copyAttachments } } }, })); @@ -152,7 +157,7 @@ vi.mock("@/hooks/queries/host-queries", () => ({ useHosts: () => ({ data: [{ id: "host_1", name: "Machine" }], }), - selectPersistentHosts: (hosts: T[] | undefined) => hosts ?? [], + selectHosts: (hosts: T[] | undefined) => hosts ?? [], selectPrimaryHost: ( hosts: Array<{ id: string }> | undefined, primaryHostId: string | null, @@ -1812,3 +1817,32 @@ describe("NewThreadComposer environment providers", () => { }); }); }); + +it("submits the visible model explicitly when a new machine cannot resolve a catalog default", () => { + expect( + resolveSubmittedExecutionSources( + { + type: "provider", + environmentProviderId: "project-checkout", + inputs: null, + machine: { + type: "new", + machineProviderId: "modal-sandbox", + inputs: null, + }, + }, + {}, + ), + ).toEqual({ model: "explicit" }); + expect( + resolveSubmittedExecutionSources( + { + type: "provider", + environmentProviderId: "project-checkout", + inputs: null, + machine: { type: "existing", hostId: "host_1" }, + }, + {}, + ), + ).toEqual({}); +}); diff --git a/apps/app/src/components/plugin/new-thread-environment-seed.test.ts b/apps/app/src/components/plugin/new-thread-environment-seed.test.ts index d9a3a6bc10..002e8d51b8 100644 --- a/apps/app/src/components/plugin/new-thread-environment-seed.test.ts +++ b/apps/app/src/components/plugin/new-thread-environment-seed.test.ts @@ -224,6 +224,20 @@ describe("newThreadEnvironmentArgsToSeed round trip", () => { expect(roundTrip(environment)).toEqual(environment); }); + it("a provider on a new machine keeps its inputs verbatim", () => { + const environment: CreateThreadEnvironmentArgs = { + type: "provider", + environmentProviderId: "container", + machine: { + type: "new", + machineProviderId: "container-machine", + inputs: { target: "primary" }, + }, + inputs: { image: "custom:latest" }, + }; + expect(roundTrip(environment)).toEqual(environment); + }); + it("an unregistered provider resolves to no environment", () => { const seed = newThreadEnvironmentArgsToSeed({ type: "provider", diff --git a/apps/app/src/components/plugin/new-thread-environment-seed.ts b/apps/app/src/components/plugin/new-thread-environment-seed.ts index 02b01b0414..a8cb3fff96 100644 --- a/apps/app/src/components/plugin/new-thread-environment-seed.ts +++ b/apps/app/src/components/plugin/new-thread-environment-seed.ts @@ -65,7 +65,10 @@ export function newThreadEnvironmentArgsToSeed( return { selectionValue: encodeProviderValue(environment.environmentProviderId), providerMachine: environment.machine, - providerHostId: environment.machine.hostId, + providerHostId: + environment.machine.type === "existing" + ? environment.machine.hostId + : null, providerInputs: environment.inputs, }; } diff --git a/apps/app/src/components/promptbox/NewThreadComposer.tsx b/apps/app/src/components/promptbox/NewThreadComposer.tsx index d96e82bf86..1e9f590f02 100644 --- a/apps/app/src/components/promptbox/NewThreadComposer.tsx +++ b/apps/app/src/components/promptbox/NewThreadComposer.tsx @@ -22,11 +22,13 @@ import { import type { NewThreadRequest, PluginEnvironmentProviderInputsChange, + PluginMachineProviderInputsChange, } from "@get-bb/plugin-sdk"; import type { CreateExecutionInputSources, SidebarBootstrapResponse, SystemEnvironmentProvider, + SystemMachineProvider, SystemExecutionOptionsModelLoadError, } from "@bb/server-contract"; import type { ProjectSelectorCreateProjectConfig } from "@/components/pickers/ProjectSelector"; @@ -36,6 +38,7 @@ import { parseEnvironmentValue, } from "@/components/pickers/environment-picker-value"; import { providerInputsControlRequired } from "@/components/pickers/environment-provider-inputs"; +import { machineProviderInputsControlRequired } from "@/components/pickers/machine-provider-inputs"; import { formatModelLoadErrorText } from "@/components/pickers/model-load-error-message"; import { NewThreadPromptBox, @@ -57,8 +60,9 @@ import { useSystemEnvironmentProviders, useSystemEnvironmentProvidersByHost, } from "@/hooks/queries/environment-provider-queries"; +import { useSystemMachineProviders } from "@/hooks/queries/machine-provider-queries"; import { - selectPersistentHosts, + selectHosts, selectPrimaryHost, useHosts, } from "@/hooks/queries/host-queries"; @@ -176,6 +180,15 @@ export interface NewThreadComposerState { renderPromptBox: (options: NewThreadComposerPromptOptions) => ReactNode; } +export function resolveSubmittedExecutionSources( + environment: NewThreadRequest["environment"], + sources: CreateExecutionInputSources, +): CreateExecutionInputSources { + return environment.type === "provider" && environment.machine?.type === "new" + ? { ...sources, model: "explicit" } + : sources; +} + export interface NewThreadComposerSubmission extends NewThreadRequest { sendAt?: number; } @@ -415,7 +428,7 @@ export function NewThreadComposer({ const hostsQuery = useHosts(); const availableHosts = useMemo( - () => selectPersistentHosts(hostsQuery.data), + () => selectHosts(hostsQuery.data), [hostsQuery.data], ); const systemConfigQuery = useSystemConfig(); @@ -483,6 +496,10 @@ export function NewThreadComposer({ environmentProvidersByHostId, ], ); + const { providers: machineProviders } = useSystemMachineProviders( + isProjectless ? {} : { projectId }, + ); + const seedSignature = JSON.stringify([ resetKey ?? null, seed?.providerId ?? null, @@ -543,6 +560,7 @@ export function NewThreadComposer({ ? environmentSeed.providerMachine : null; const candidate = picked ?? seeded; + if (candidate?.type === "new") return { provider, machine: candidate }; if (usable(candidate?.hostId ?? null)) { return { provider, machine: candidate }; } @@ -743,6 +761,37 @@ export function NewThreadComposer({ }, [changeEnvironment], ); + const handleSelectMachineProvider = useCallback( + (provider: SystemMachineProvider) => { + const current = parseEnvironmentValue(environmentSelectionValue); + const environmentProviderId = + provider.environmentRow?.environmentProviderId ?? + (current?.type === "provider" + ? current.environmentProviderId + : environmentProviders?.find((entry) => + isProjectless + ? entry.requires.projectless + : entry.id === PROJECT_CHECKOUT_ENVIRONMENT_PROVIDER_ID, + )?.id); + if (environmentProviderId === undefined) return; + changeEnvironment(encodeProviderValue(environmentProviderId), { + type: "new", + machineProviderId: provider.id, + inputs: + provider.inputs === null + ? null + : provider.acceptsEmptyInputs + ? {} + : null, + }); + }, + [ + changeEnvironment, + environmentSelectionValue, + environmentProviders, + isProjectless, + ], + ); const effectiveEnvironmentValue = useMemo( () => resolveRootComposeEffectiveEnvironmentValue({ @@ -778,6 +827,12 @@ export function NewThreadComposer({ const providerMachine = providerSelection?.machine ?? null; const providerHostId = providerMachine?.type === "existing" ? providerMachine.hostId : null; + const selectedMachineProvider = + providerMachine?.type === "new" + ? machineProviders?.find( + (provider) => provider.id === providerMachine.machineProviderId, + ) + : undefined; const selectedScopedEnvironmentProvider = useMemo(() => { if (selectedEnvironmentProvider === undefined) return undefined; if (providerHostId === null) return undefined; @@ -790,9 +845,12 @@ export function NewThreadComposer({ selectedEnvironmentProvider, ]); const setupRequiredPluginId = - selectedScopedEnvironmentProvider?.availability?.status === "setup-required" - ? selectedScopedEnvironmentProvider.pluginId - : null; + selectedMachineProvider?.availability?.status === "setup-required" + ? selectedMachineProvider.pluginId + : selectedScopedEnvironmentProvider?.availability?.status === + "setup-required" + ? selectedScopedEnvironmentProvider.pluginId + : null; const gitCheckoutProviderSelected = selectedEnvironmentProvider?.requires.gitCheckout === true; const projectCheckoutProviderSelected = @@ -947,6 +1005,12 @@ export function NewThreadComposer({ : null; const environmentProviderInputsSlot = useMemo(() => { if (environmentProviderInputsRegistration === undefined) return null; + if ( + providerMachine?.type === "new" && + selectedEnvironmentProvider?.id === + PROJECT_CHECKOUT_ENVIRONMENT_PROVIDER_ID + ) + return null; const InputsComponent = environmentProviderInputsRegistration.component; return ( { + const pluginIdByProviderId = new Map( + (machineProviders ?? []).map((provider) => [ + provider.id, + provider.pluginId, + ]), + ); + return new Set( + machineProviderInputsSlots + .filter( + (slot) => + pluginIdByProviderId.get(slot.machineProviderId) === slot.pluginId, + ) + .map((slot) => slot.machineProviderId), + ); + }, [machineProviderInputsSlots, machineProviders]); + const machineProviderInputsRegistration = useMemo(() => { + if (selectedMachineProvider === undefined) { + return undefined; + } + return machineProviderInputsSlots.find( + (slot) => + slot.machineProviderId === selectedMachineProvider.id && + slot.pluginId === selectedMachineProvider.pluginId, + ); + }, [machineProviderInputsSlots, selectedMachineProvider]); + const machineProviderInputsScopeKey = `${projectId}\0${selectedMachineProvider?.id ?? ""}`; + const [machineProviderInputsOverride, setMachineProviderInputsOverride] = + useState<{ scopeKey: string; value: JsonValue } | null>(null); + const [machineProviderInputsBlocked, setMachineProviderInputsBlocked] = + useState<{ scopeKey: string; reason: string } | null>(null); + const handleMachineProviderInputsChange = useCallback( + (next: PluginMachineProviderInputsChange) => { + if (next.status === "blocked") { + setMachineProviderInputsBlocked({ + scopeKey: machineProviderInputsScopeKey, + reason: next.reason, + }); + return; + } + setMachineProviderInputsBlocked(null); + setMachineProviderInputsOverride({ + scopeKey: machineProviderInputsScopeKey, + value: next.value, + }); + }, + [machineProviderInputsScopeKey], + ); + const activeMachineInputsOverride = + machineProviderInputsOverride?.scopeKey === machineProviderInputsScopeKey + ? machineProviderInputsOverride + : null; + const activeMachineInputsBlocked = + machineProviderInputsBlocked?.scopeKey === machineProviderInputsScopeKey + ? machineProviderInputsBlocked + : null; + const machineProviderTakesInputs = selectedMachineProvider?.inputs !== null; + const machineInputsControlRequired = + selectedMachineProvider !== undefined && + machineProviderInputsControlRequired(selectedMachineProvider); + const submissionMachineInputs = useMemo( + () => + selectedMachineProvider === undefined || !machineProviderTakesInputs + ? null + : (activeMachineInputsOverride?.value ?? + (providerMachine?.type === "new" ? providerMachine.inputs : null) ?? + (machineProviderInputsRegistration === undefined && + !machineInputsControlRequired + ? {} + : null)), + [ + activeMachineInputsOverride?.value, + machineInputsControlRequired, + machineProviderInputsRegistration, + machineProviderTakesInputs, + providerMachine, + selectedMachineProvider, + ], + ); + const machineProviderInputsBlocker = + selectedMachineProvider === undefined || !machineProviderTakesInputs + ? null + : activeMachineInputsBlocked !== null + ? activeMachineInputsBlocked.reason + : machineProviderInputsRegistration === undefined && + machineInputsControlRequired + ? `${selectedMachineProvider.displayName} needs its plugin's control` + : submissionMachineInputs === null + ? `Configure ${selectedMachineProvider.displayName}` + : null; + const machineProviderInputsSlot = useMemo(() => { + if (machineProviderInputsRegistration === undefined) return null; + const MachineProviderInputsComponent = + machineProviderInputsRegistration.component; + return ( + + + + ); + }, [ + handleMachineProviderInputsChange, + isProjectless, + machineProviderInputsRegistration, + projectId, + selectedProviderId, + submissionMachineInputs, + ]); + const submissionProviderMachine = useMemo( + () => + providerMachine?.type === "new" + ? { ...providerMachine, inputs: submissionMachineInputs } + : providerMachine, + [providerMachine, submissionMachineInputs], + ); + const selectedEnvironment = useMemo( () => resolveRootComposeThreadEnvironment({ environmentValue: effectiveEnvironmentValue, projectId, environmentProviders, - providerMachine: providerMachine, + providerMachine: submissionProviderMachine, providerInputs: submissionProviderInputs, }), [ @@ -985,7 +1176,7 @@ export function NewThreadComposer({ environmentProviders, projectId, submissionProviderInputs, - providerMachine, + submissionProviderMachine, ], ); @@ -1237,7 +1428,8 @@ export function NewThreadComposer({ (selectionScope === "new-thread" ? seed?.environment : undefined) ?? null; const submitDisabledReason = resolveNewThreadSubmitDisabledReason({ - environmentProviderInputsBlocker: environmentProviderInputsBlocker, + environmentProviderInputsBlocker: + machineProviderInputsBlocker ?? environmentProviderInputsBlocker, isCopyingAttachments, isLoadingModels, isSubmitting, @@ -1288,7 +1480,10 @@ export function NewThreadComposer({ reasoningLevel, permissionMode, ...(supportsServiceTier && serviceTier ? { serviceTier } : {}), - executionInputSources: sources, + executionInputSources: resolveSubmittedExecutionSources( + submissionEnvironment, + sources, + ), environment: submissionEnvironment, input, ...(sendAt === null ? {} : { sendAt }), @@ -1470,6 +1665,10 @@ export function NewThreadComposer({ selectedProviderHostId: providerHostId, inputsControlProviderIds, onSelectProvider: handleSelectProvider, + machineProviders: machineProviders ?? [], + selectedMachineProviderId: selectedMachineProvider?.id ?? null, + machineInputsControlProviderIds, + onSelectMachineProvider: handleSelectMachineProvider, ...(!isProjectless && options.onRequestMachineSetup ? { onRequestMachineSetup: options.onRequestMachineSetup } : {}), @@ -1487,6 +1686,7 @@ export function NewThreadComposer({ supported: supportsPermissionModeSelection, }, environmentProviderInputsSlot, + machineProviderInputsSlot, banner: options.banner, header: options.header, }} @@ -1556,6 +1756,7 @@ export function NewThreadComposer({ handleProviderChange, handleReasoningChange, handleSelectProvider, + handleSelectMachineProvider, handleServiceTierChange, handleSubmit, handleWorktreeChange, @@ -1593,8 +1794,12 @@ export function NewThreadComposer({ supportsServiceTier, submitDisabledReason, environmentProviderInputsSlot, + machineProviderInputsSlot, environmentProvidersByHostId, inputsControlProviderIds, + machineInputsControlProviderIds, + machineProviders, + selectedMachineProvider, providerHostId, textEffects, serviceTierFastLabel, diff --git a/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx b/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx index bc0ada64fb..79c047bba3 100644 --- a/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx +++ b/apps/app/src/components/promptbox/NewThreadEnvironmentOptions.stories.tsx @@ -17,6 +17,7 @@ import { STORY_BRANCH_OPTIONS, STORY_PROJECTS, STORY_ENVIRONMENT_PROVIDERS, + STORY_MACHINE_PROVIDERS, STORY_PROJECT_SOURCES, STORY_WORKTREE_OPTIONS, } from "../../../.ladle/story-fixtures"; @@ -68,6 +69,8 @@ function EnvironmentOptionsStrip({ projectless={projectless} selectedProviderHostId={HOST_IDS.local} onSelectProvider={noop} + machineProviders={STORY_MACHINE_PROVIDERS} + onSelectMachineProvider={noop} muted modal={false} {...environment} diff --git a/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx b/apps/app/src/components/promptbox/NewThreadPromptBox.test.tsx index 81ba70ca98..bc25c57ba5 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 { ProjectlessEnvSlot, ProjectlessMachineSlot, @@ -203,13 +206,37 @@ describe("ProjectlessEnvSlot", () => { inputs: null, }; + const modalMachineProvider: SystemMachineProvider = { + id: "modal-sandbox", + displayName: "Modal sandbox", + icon: "Box", + logoUrl: null, + pluginId: "environment-modal-sandbox", + requires: { gitRemote: true }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + environmentRow: { + displayName: "Modal sandbox", + environmentProviderId: personalProvider.id, + }, + policy: { + idleSuspendMs: 60_000, + retire: { after: "last-thread", graceMs: 60_000 }, + removeRetryMs: 60_000, + }, + availability: null, + }; + function makeEnvironment(overrides: { value?: string; providers?: readonly SystemEnvironmentProvider[]; + machineProviders?: readonly SystemMachineProvider[]; onSelectProvider?: ( provider: SystemEnvironmentProvider, hostId: string | null, ) => void; + onSelectMachineProvider?: (provider: SystemMachineProvider) => void; }) { return { value: overrides.value ?? "provider:personal-workspace", @@ -225,6 +252,8 @@ describe("ProjectlessEnvSlot", () => { providers: overrides.providers ?? [personalProvider], selectedProviderHostId: host.id, onSelectProvider: overrides.onSelectProvider ?? vi.fn(), + machineProviders: overrides.machineProviders, + onSelectMachineProvider: overrides.onSelectMachineProvider, }; } @@ -274,6 +303,26 @@ describe("ProjectlessEnvSlot", () => { expect(screen.queryByText("Modal sandbox")).toBeNull(); }); + it("shows machine-provider sugar after the personal workspace option", () => { + render( + , + ); + + expect(screen.queryByRole("button", { name: "Machine" })).toBeNull(); + const trigger = screen.getByRole("button", { name: "Environment" }); + fireEvent.pointerDown(trigger, { button: 0 }); + const items = screen.getAllByRole("menuitem"); + expect(items[0]?.textContent).toContain("Personal workspace"); + expect(items.at(-1)?.textContent).toContain("Modal sandbox"); + }); + 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 cae81d65fb..783b40385d 100644 --- a/apps/app/src/components/promptbox/NewThreadPromptBox.tsx +++ b/apps/app/src/components/promptbox/NewThreadPromptBox.tsx @@ -56,7 +56,7 @@ import { type ReuseThreadOption, } from "@/components/pickers/ReuseEnvironmentPicker"; import { - selectPersistentHosts, + selectHosts, selectPrimaryHost, useHosts, } from "@/hooks/queries/host-queries"; @@ -87,6 +87,10 @@ export interface NewThreadEnvironmentConfig { selectedProviderHostId?: string | null; inputsControlProviderIds?: ReadonlySet; onSelectProvider?: EnvironmentPickerUIProps["onSelectProvider"]; + machineProviders?: EnvironmentPickerUIProps["machineProviders"]; + selectedMachineProviderId?: string | null; + machineInputsControlProviderIds?: ReadonlySet; + onSelectMachineProvider?: EnvironmentPickerUIProps["onSelectMachineProvider"]; } export interface NewThreadWorktreeConfig { @@ -112,6 +116,7 @@ export interface NewThreadModeConfig { worktree: NewThreadWorktreeConfig; permission: ExecutionPermissionConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; banner?: ReactNode; header?: ReactNode; } @@ -375,6 +380,7 @@ const DefaultNewThreadComposer = memo(function DefaultNewThreadComposer({ environmentProviderInputsSlot={ modeConfig.environmentProviderInputsSlot } + machineProviderInputsSlot={modeConfig.machineProviderInputsSlot} /> ) : ( )} @@ -406,12 +413,14 @@ interface ThreadEnvSlotProps { environment: NewThreadEnvironmentConfig; worktree: NewThreadWorktreeConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; } export function ThreadEnvSlot({ environment, worktree, environmentProviderInputsSlot, + machineProviderInputsSlot, }: ThreadEnvSlotProps) { const parsedEnvironment = useMemo( () => parseEnvironmentValue(environment.value), @@ -443,6 +452,12 @@ export function ThreadEnvSlot({ selectedProviderHostId={environment.selectedProviderHostId} inputsControlProviderIds={environment.inputsControlProviderIds} onSelectProvider={environment.onSelectProvider} + machineProviders={environment.machineProviders} + selectedMachineProviderId={environment.selectedMachineProviderId} + machineInputsControlProviderIds={ + environment.machineInputsControlProviderIds + } + onSelectMachineProvider={environment.onSelectMachineProvider} className="shrink-0" muted /> @@ -458,6 +473,10 @@ export function ThreadEnvSlot({ {selectedProvider !== undefined && selectedProvider.inputs !== null ? environmentProviderInputsSlot : null} + {environment.selectedMachineProviderId === undefined || + environment.selectedMachineProviderId === null + ? null + : machineProviderInputsSlot} ); } @@ -466,12 +485,14 @@ interface ProjectlessEnvSlotProps { environment: NewThreadEnvironmentConfig; worktree: NewThreadWorktreeConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; } export function ProjectlessEnvSlot({ environment, worktree, environmentProviderInputsSlot, + machineProviderInputsSlot, }: ProjectlessEnvSlotProps) { const providers = (environment.providers ?? []).filter( (provider) => provider.requires.projectless, @@ -487,7 +508,15 @@ export function ProjectlessEnvSlot({ ) : undefined; const showReuseEnvironmentPicker = parsedEnvironment?.type === "reuse"; - if (providers.length <= 1 && !showReuseEnvironmentPicker) { + const hasMachineProviderEnvironmentOptions = + environment.onSelectMachineProvider !== undefined && + (environment.machineProviders?.length ?? 0) > 0; + + if ( + providers.length <= 1 && + !showReuseEnvironmentPicker && + !hasMachineProviderEnvironmentOptions + ) { return ; } @@ -506,6 +535,12 @@ export function ProjectlessEnvSlot({ selectedProviderHostId={environment.selectedProviderHostId} inputsControlProviderIds={environment.inputsControlProviderIds} onSelectProvider={environment.onSelectProvider} + machineProviders={environment.machineProviders} + selectedMachineProviderId={environment.selectedMachineProviderId} + machineInputsControlProviderIds={ + environment.machineInputsControlProviderIds + } + onSelectMachineProvider={environment.onSelectMachineProvider} className="shrink-0" muted /> @@ -521,6 +556,10 @@ export function ProjectlessEnvSlot({ {selectedProvider !== undefined && selectedProvider.inputs !== null ? environmentProviderInputsSlot : null} + {environment.selectedMachineProviderId === undefined || + environment.selectedMachineProviderId === null + ? null + : machineProviderInputsSlot} ); } @@ -534,7 +573,7 @@ export function ProjectlessMachineSlot({ }: ProjectlessMachineSlotProps) { const machines = environment.machines ?? null; const availableHosts = useMemo( - () => selectPersistentHosts(machines?.hosts), + () => selectHosts(machines?.hosts), [machines?.hosts], ); const parsedEnvironment = useMemo( @@ -590,6 +629,7 @@ interface NewThreadConnectedModeConfig { worktree: NewThreadWorktreeConfig; permission: ExecutionPermissionConfig; environmentProviderInputsSlot?: ReactNode; + machineProviderInputsSlot?: ReactNode; banner?: ReactNode; header?: ReactNode; } @@ -608,7 +648,7 @@ export function NewThreadPromptBox({ const { data: hosts } = useHosts(); const systemConfigQuery = useSystemConfig(); const primaryHostId = systemConfigQuery.data?.primaryHostId ?? null; - const availableHosts = useMemo(() => selectPersistentHosts(hosts), [hosts]); + const availableHosts = useMemo(() => selectHosts(hosts), [hosts]); const primaryHost = useMemo( () => selectPrimaryHost(availableHosts, primaryHostId), [availableHosts, primaryHostId], @@ -654,6 +694,7 @@ export function NewThreadPromptBox({ permission: threadConfig.permission, environmentProviderInputsSlot: threadConfig.environmentProviderInputsSlot, + machineProviderInputsSlot: threadConfig.machineProviderInputsSlot, banner: threadConfig.banner, header: threadConfig.header, }} 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/settings/MachineAccessSettings.test.tsx b/apps/app/src/components/settings/MachineAccessSettings.test.tsx new file mode 100644 index 0000000000..799cda18ac --- /dev/null +++ b/apps/app/src/components/settings/MachineAccessSettings.test.tsx @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, expect, it, vi } from "vitest"; +import { makeSystemConfig } from "@/test/fixtures/system-config"; +import { MachineAccessSettings } from "./MachineAccessSettings"; + +vi.mock("@/hooks/queries/system-queries", () => ({ + useSystemConfig: () => ({ + data: makeSystemConfig({ + serverAccess: { + providers: [ + { + id: "connect", + displayName: "bb Cloud", + availability: { status: "available" }, + attention: "2 legacy access records need attention", + }, + ], + defaultProviderId: "connect", + effectiveUrl: null, + urlSource: null, + }, + }), + }), +})); +vi.mock("@/hooks/mutations/settings-mutations", () => ({ + useUpdateGeneralSettings: () => ({ isPending: false }), +})); +afterEach(cleanup); +it("shows access attention in General settings even when automatic access is available", () => { + render(); + expect(screen.getByRole("status").textContent).toBe( + "bb Cloud: 2 legacy access records need attention", + ); + expect(screen.getByText("Automatic: bb Cloud")).toBeTruthy(); +}); diff --git a/apps/app/src/components/settings/MachineAccessSettings.tsx b/apps/app/src/components/settings/MachineAccessSettings.tsx new file mode 100644 index 0000000000..9cd2a9e753 --- /dev/null +++ b/apps/app/src/components/settings/MachineAccessSettings.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { Input } from "@bb/shared-ui/input"; +import { Button } from "@bb/shared-ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@bb/shared-ui/dropdown-menu"; +import { useSystemConfig } from "@/hooks/queries/system-queries"; +import { useUpdateGeneralSettings } from "@/hooks/mutations/settings-mutations"; +import { + SettingsSection, + SettingsWithControl, +} from "@/components/ui/settings-section"; + +export function MachineAccessSettings() { + 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 commitUrl = async () => { + if (!settings || draft === null) return; + try { + const url = draft.trim(); + if (url) { + const parsed = new URL(url); + if ( + !["http:", "https:"].includes(parsed.protocol) || + parsed.username || + parsed.password + ) + throw new Error("Enter an HTTP or HTTPS URL without credentials"); + } + await update.mutateAsync({ ...settings, machineServerUrl: url || null }); + setDraft(null); + setError(null); + } catch { + setError("Enter a valid HTTP or HTTPS URL without credentials"); + } + }; + const selected = settings?.defaultMachineAccess; + const effective = access?.providers.find( + (provider) => provider.id === access.defaultProviderId, + ); + return ( + + {access?.providers.map((provider) => + provider.attention ? ( +

+ {provider.displayName}: {provider.attention} +

+ ) : null, + )} + + setDraft(event.target.value)} + onBlur={() => void commitUrl()} + onKeyDown={(event) => { + if (event.key === "Enter") void commitUrl(); + }} + /> + + + + + + + + { + if (settings) + update.mutate({ ...settings, defaultMachineAccess: null }); + }} + > + Automatic + + {access?.providers.map((provider) => ( + { + if (settings) + update.mutate({ + ...settings, + defaultMachineAccess: provider.id, + }); + }} + > + {provider.displayName} + + ))} + + + + + git: {config.data?.machineGit.status ?? "not configured"} + +
+ ); +} diff --git a/apps/app/src/components/settings/MachineEnvironmentSettings.tsx b/apps/app/src/components/settings/MachineEnvironmentSettings.tsx new file mode 100644 index 0000000000..1981e7abb0 --- /dev/null +++ b/apps/app/src/components/settings/MachineEnvironmentSettings.tsx @@ -0,0 +1,159 @@ +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import type { + MachineEnvironmentSet, + 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 { + SettingsSection, + SettingsWithControl, +} from "@/components/ui/settings-section"; +import { invalidateSystemConfig } from "@/hooks/cache-owners/system-cache-effects"; + +const queryKey = ["machine-environment"]; +const empty: MachineEnvironmentSet = { + name: "", + value: "", + secret: false, + note: null, +}; + +export function MachineEnvironmentSettings() { + const queryClient = useQueryClient(); + const query = useQuery({ + queryKey, + queryFn: () => sdk.system.machineEnvironment(), + }); + const [draft, setDraft] = useState(empty); + const [editing, setEditing] = useState(false); + const [error, setError] = useState(null); + const mutation = useMutation({ + mutationFn: (input: MachineEnvironmentSet | string) => + typeof input === "string" + ? sdk.system.unsetMachineEnvironment(input) + : sdk.system.setMachineEnvironment(input), + onSuccess: () => { + void query.refetch(); + invalidateSystemConfig({ queryClient }); + setDraft(empty); + setEditing(false); + setError(null); + }, + onError: () => + setError( + "Could not update the machine environment. Use a valid variable name and try again.", + ), + }); + const edit = (row: MachineEnvironmentVariable) => { + setDraft({ ...row, value: row.value ?? "" }); + setEditing(true); + }; + return ( + + + {query.data?.builtInGit.status ?? "Checking…"} + + {query.data?.variables.map((row) => ( + +
+ + +
+
+ ))} +
{ + event.preventDefault(); + mutation.mutate(draft); + }} + > + setDraft({ ...draft, name: event.target.value })} + /> + + setDraft({ ...draft, value: event.target.value }) + } + /> + + + setDraft({ ...draft, note: event.target.value || null }) + } + /> + {error ? ( +

+ {error} +

+ ) : null} +
+ + {editing ? ( + + ) : null} +
+
+
+ ); +} diff --git a/apps/app/src/components/settings/MachinesSettingsSection.test.tsx b/apps/app/src/components/settings/MachinesSettingsSection.test.tsx index 3727138ece..5ce45fa894 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 } from "react-router-dom"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { sdk } from "@/lib/sdk"; @@ -24,7 +27,11 @@ vi.mock("@/lib/sdk", () => ({ hosts: { delete: vi.fn(), list: vi.fn(), + listProviders: vi.fn(), + resume: vi.fn(), + retryCleanup: vi.fn(), retryUpdate: vi.fn(), + suspend: vi.fn(), update: vi.fn(), }, system: { config: vi.fn() }, @@ -63,6 +70,27 @@ const offlineHost = host({ status: "disconnected", lastSeenAt: NOW - 2 * 60 * 60 * 1000, }); +const modalProvider: SystemMachineProvider = { + id: "modal-sandbox", + displayName: "Modal sandbox", + icon: "./modal-logo.svg", + logoUrl: "/api/v1/system/providers/machine%3Amodal-sandbox/logo?h=hash", + pluginId: "environment-modal-sandbox", + requires: { gitRemote: true }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + environmentRow: { + displayName: "Modal sandbox", + environmentProviderId: "project-checkout", + }, + policy: { + idleSuspendMs: 60_000, + retire: { after: "last-thread", graceMs: 60_000 }, + removeRetryMs: 60_000, + }, + availability: null, +}; function systemConfig(): SystemConfigResponse { return makeSystemConfig({ @@ -119,6 +147,7 @@ async function openHostMenu(hostName: string): Promise { beforeEach(() => { hostDaemon.localDaemonHostId = "host_primary"; hostDaemon.platform = "darwin"; + vi.mocked(sdk.hosts.listProviders).mockResolvedValue([]); }); afterEach(() => { @@ -128,6 +157,115 @@ afterEach(() => { }); describe("MachinesSettingsSection", () => { + it("keeps provider-made hosts visible and marks them with the provider", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.listProviders).mockResolvedValue([modalProvider]); + vi.mocked(sdk.hosts.list).mockResolvedValue([ + primaryHost, + host({ + id: "host_modal", + name: "Modal sandbox 3f9a", + machineProviderId: modalProvider.id, + }), + ]); + stubSidebarBootstrapFetch(); + + renderSection(); + + const name = await screen.findByText("Modal sandbox 3f9a"); + expect(name.parentElement?.textContent).toContain("Modal sandbox"); + expect(screen.queryByText("Active")).toBeNull(); + expect( + name.parentElement + ?.querySelector("[data-provider-logo]") + ?.getAttribute("data-provider-logo"), + ).toBe(modalProvider.logoUrl); + }); + + it("renders a provider-made machine without branding when its provider omits an icon", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.listProviders).mockResolvedValue([ + { + ...modalProvider, + id: "test-machine", + displayName: "Test machine", + icon: null, + logoUrl: null, + pluginId: "test-machine-provider", + supportsSuspend: false, + environmentRow: null, + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 60_000, + }, + }, + ]); + vi.mocked(sdk.hosts.list).mockResolvedValue([ + primaryHost, + host({ + id: "host_test", + name: "test-machine-host", + machineProviderId: "test-machine", + }), + ]); + stubSidebarBootstrapFetch(); + + renderSection(); + + const name = await screen.findByText("test-machine-host"); + expect(name.parentElement?.textContent).not.toContain("Test machine"); + expect( + name.parentElement?.querySelector("[data-provider-logo]"), + ).toBeNull(); + expect(name.parentElement?.querySelector('[data-icon="Zap"]')).toBeNull(); + }); + + it("shows suspend for a capable provider", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.listProviders).mockResolvedValue([modalProvider]); + vi.mocked(sdk.hosts.list).mockResolvedValue([ + host({ + id: "host_modal", + name: "Modal active", + machineProviderId: modalProvider.id, + }), + ]); + stubSidebarBootstrapFetch(); + renderSection(); + + await openHostMenu("Modal active"); + expect( + await screen.findByRole("menuitem", { name: "Suspend" }), + ).toBeDefined(); + }); + + it("shows retry cleanup only after teardown fails", async () => { + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([ + host({ + id: "host_failed", + name: "Cleanup target", + machineProviderId: modalProvider.id, + lifecycle: { + phase: "retiring", + suspendedAt: null, + retireAt: NOW, + progress: null, + teardown: { status: "failed", attempt: 1, message: "failed" }, + }, + }), + ]); + stubSidebarBootstrapFetch(); + renderSection(); + + expect(await screen.findByText("Cleanup failed")).toBeDefined(); + await openHostMenu("Cleanup target"); + expect( + await screen.findByRole("menuitem", { name: "Retry cleanup" }), + ).toBeDefined(); + }); + 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]); diff --git a/apps/app/src/components/settings/MachinesSettingsSection.tsx b/apps/app/src/components/settings/MachinesSettingsSection.tsx index 1dc29b9833..12baa1a6f7 100644 --- a/apps/app/src/components/settings/MachinesSettingsSection.tsx +++ b/apps/app/src/components/settings/MachinesSettingsSection.tsx @@ -1,6 +1,9 @@ +import { MachineLifecycleNotice } from "@/components/machines/MachineLifecycleNotice"; +import { MachineProviderDetails } from "@/components/machines/MachineProviderDetails"; import { useMemo, useState } from "react"; import { Link } 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"; @@ -25,11 +28,13 @@ import { TooltipProvider, TooltipTrigger, } from "@bb/shared-ui/tooltip"; -import { AddMachineDialog } from "@/components/dialogs/AddMachineDialog"; +import { CreateMachineDialog } from "@/components/dialogs/CreateMachineDialog"; import { ConfirmDeleteDialog } from "@/components/dialogs/ConfirmDeleteDialog"; import { appToast } from "@/components/ui/app-toast"; import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; +import { MachinePhaseBadge } from "@/components/machines/MachinePhaseBadge"; import { MachineRenameDialog } from "@/components/settings/MachineRenameDialog"; +import { MachineProviderIcon } from "@/components/plugin/MachineProviderIcon"; import { SettingsBadge, SettingsRow, @@ -39,9 +44,13 @@ import { 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"; @@ -86,7 +95,13 @@ interface MachineRowProps { onRename: () => void; onRemove: () => void; onRetryUpdate: () => void; + onSuspend: () => void; + onResume: () => void; + onRetryCleanup: () => void; + lifecycleActionPending: boolean; retryUpdatePending: boolean; + machineProvider: SystemMachineProvider | null; + gitStatus: "ready" | "not configured" | null; } function MachineRow({ @@ -100,16 +115,23 @@ function MachineRow({ onRename, onRemove, onRetryUpdate, + onSuspend, + onResume, + onRetryCleanup, + lifecycleActionPending, retryUpdatePending, + machineProvider, + gitStatus, }: MachineRowProps) { const permission = PERMISSION_MODE_PRESENTATION[host.maxPermissionMode]; const projectLabel = `${projectCount} ${projectCount === 1 ? "project" : "projects"}`; const connectionLabel = - host.status === "connected" + host.lifecycle.progress ?? + (host.status === "connected" ? "Online" : host.lastSeenAt === null ? "Offline" - : `Offline · last seen ${formatRelativeTime({ timestamp: host.lastSeenAt, now })}`; + : `Offline · last seen ${formatRelativeTime({ timestamp: host.lastSeenAt, now })}`); const updateStatus = formatHostUpdateStatus(host); const removeItem = ( -
- +
-
-
- - {host.name} - - {isThisMachine ? ( - this machine - ) : null} - {showPrimaryBadge ? primary : null} -
-
- - - {connectionLabel} - - {platformLabel === null ? null : ( - {platformLabel} - )} - {projectLabel} - +
+
+ + {host.name} + + {isThisMachine ? ( + this machine + ) : null} + {showPrimaryBadge ? ( + primary + ) : null} + + {machineProvider?.icon == null ? null : ( + + + + {machineProvider.displayName} + + )} - > - {permission.label} - - {updateStatus === null ? null : ( - - {updateStatus} +
+
+ + + {connectionLabel} - )} -
-
- -
- - - - - - - {platformLabel} + )} + {projectLabel} + {gitStatus === null ? null : git: {gitStatus}} + - - Rename - - {hostCanRetryUpdate(host) ? ( + {permission.label} + + {updateStatus === null ? null : ( + + {updateStatus} + + )} +
+ {host.machineProviderId ? ( + + ) : null} +
+ +
+ + + + + + - - - {retryUpdatePending ? "Retrying update…" : "Retry update"} - + + Rename - ) : null} - {isPrimary ? ( - - {removeItem} - - {PRIMARY_REMOVE_DISABLED_REASON} - - - ) : ( - removeItem - )} - - - - + {hostCanRetryUpdate(host) ? ( + + + + {retryUpdatePending + ? "Retrying update…" + : "Retry update"} + + + ) : null} + {machineProvider?.supportsSuspend && + host.lifecycle.phase === "active" ? ( + + + Suspend + + ) : null} + {machineProvider?.supportsSuspend && + host.lifecycle.phase === "suspended" ? ( + + + Resume + + ) : null} + {host.lifecycle.phase === "retiring" && + host.lifecycle.teardown?.status === "failed" ? ( + + + Retry cleanup + + ) : null} + {isPrimary ? ( + + {removeItem} + + {PRIMARY_REMOVE_DISABLED_REASON} + + + ) : ( + removeItem + )} + + + + +
+ {host.machineProviderId !== null && ( + + )}
); @@ -234,11 +314,15 @@ function MachineRow({ export function MachinesSettingsSection() { const systemConfig = useSystemConfig(); const hostsQuery = useHosts(); + 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 [renameTarget, setRenameTarget] = useState(null); const [removeTarget, setRemoveTarget] = useState(null); @@ -260,6 +344,13 @@ export function MachinesSettingsSection() { const now = Date.now(); const primaryHostPlatform = systemConfig.data?.primaryHostPlatform ?? null; const showMachineIdentityBadges = (hosts?.length ?? 0) > 1; + const machineProviderById = useMemo( + () => + new Map( + (machineProviders ?? []).map((provider) => [provider.id, provider]), + ), + [machineProviders], + ); return ( <> @@ -287,6 +378,11 @@ export function MachinesSettingsSection() { + 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) + } /> ))} )} - { if (!open && !removeHost.isPending) setRemoveTarget(null); @@ -371,9 +495,18 @@ export function MachinesSettingsSection() { 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. + {removeTarget.machineProviderId === "manual" ? ( + + Uninstall manually on the machine:{" "} + + bb machine uninstall --host-id {removeTarget.id} + + + ) : null} + {removeTarget.machineProviderId !== null && + removeTarget.machineProviderId !== "manual" + ? "This deletes the managed compute and saved snapshots. Its environments remain as read-only history." + : "Project checkouts stay on its disk, but its environments become read-only history and it cannot run new work until paired again."} {removeHost.isError ? ( diff --git a/apps/app/src/hooks/mutations/host-mutations.ts b/apps/app/src/hooks/mutations/host-mutations.ts index 1d72c9a1ed..dd7fc19402 100644 --- a/apps/app/src/hooks/mutations/host-mutations.ts +++ b/apps/app/src/hooks/mutations/host-mutations.ts @@ -74,3 +74,29 @@ export function useRetryHostUpdate() { mutationFn: (hostId: string) => sdk.hosts.retryUpdate({ hostId }), }); } + +function useHostLifecycleMutation( + mutationFn: (hostId: string) => Promise<{ ok: true }>, +) { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn, + onSuccess: () => { + invalidateHostListQueries({ queryClient }); + }, + }); +} + +export function useSuspendHost() { + return useHostLifecycleMutation((hostId) => sdk.hosts.suspend({ hostId })); +} + +export function useResumeHost() { + return useHostLifecycleMutation((hostId) => sdk.hosts.resume({ hostId })); +} + +export function useRetryHostCleanup() { + return useHostLifecycleMutation((hostId) => + sdk.hosts.retryCleanup({ hostId }), + ); +} diff --git a/apps/app/src/hooks/queries/host-queries.test.ts b/apps/app/src/hooks/queries/host-queries.test.ts index 2398b21419..425e2da689 100644 --- a/apps/app/src/hooks/queries/host-queries.test.ts +++ b/apps/app/src/hooks/queries/host-queries.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "vitest"; import type { Host } from "@bb/domain"; import { makeHost } from "@bb/test-helpers/domain-fixtures"; -import { selectPrimaryHost } from "./host-queries"; +import { selectHosts, selectPrimaryHost } from "./host-queries"; function host(overrides: Partial & Pick): Host { return makeHost({ @@ -32,8 +32,30 @@ describe("selectPrimaryHost", () => { expect(selectPrimaryHost([hosts[0]], null)?.id).toBe("host_stale"); }); + it("allows a provider-made host to be selected as primary", () => { + const sandbox = host({ + id: "host_modal", + machineProviderId: "modal-sandbox", + }); + const laptop = host({ id: "host_laptop", status: "disconnected" }); + expect(selectPrimaryHost([sandbox], null)?.id).toBe(sandbox.id); + expect(selectPrimaryHost([sandbox], sandbox.id)?.id).toBe(sandbox.id); + expect(selectPrimaryHost([sandbox, laptop], null)?.id).toBe(sandbox.id); + }); + it("returns null for an empty or missing host list", () => { expect(selectPrimaryHost(undefined, "host_a")).toBeNull(); expect(selectPrimaryHost([], null)).toBeNull(); }); }); + +describe("selectHosts", () => { + it("keeps user-enrolled and provider-made machines in menus", () => { + expect( + selectHosts([ + host({ id: "host_local" }), + host({ id: "host_modal", machineProviderId: "modal-sandbox" }), + ]).map((candidate) => candidate.id), + ).toEqual(["host_local", "host_modal"]); + }); +}); diff --git a/apps/app/src/hooks/queries/host-queries.ts b/apps/app/src/hooks/queries/host-queries.ts index 05a09dab95..5f09413023 100644 --- a/apps/app/src/hooks/queries/host-queries.ts +++ b/apps/app/src/hooks/queries/host-queries.ts @@ -24,9 +24,7 @@ export function useHosts(options?: QueryOptions) { }); } -export function selectPersistentHosts( - hosts: readonly Host[] | undefined, -): Host[] { +export function selectHosts(hosts: readonly Host[] | undefined): Host[] { return hosts ? [...hosts] : []; } @@ -34,11 +32,16 @@ export function selectPrimaryHost( hosts: readonly Host[] | undefined, primaryHostId: string | null, ): Host | null { - if (!hosts || hosts.length === 0) return null; + const availableHosts = selectHosts(hosts); + if (availableHosts.length === 0) return null; if (primaryHostId !== null) { - return hosts.find((host) => host.id === primaryHostId) ?? null; + return availableHosts.find((host) => host.id === primaryHostId) ?? null; } - return hosts.find((host) => host.status === "connected") ?? hosts[0] ?? null; + return ( + availableHosts.find((host) => host.status === "connected") ?? + availableHosts[0] ?? + null + ); } export function usePrimaryHost(options?: QueryOptions): Host | null { diff --git a/apps/app/src/hooks/queries/machine-provider-queries.ts b/apps/app/src/hooks/queries/machine-provider-queries.ts new file mode 100644 index 0000000000..ced10afc3c --- /dev/null +++ b/apps/app/src/hooks/queries/machine-provider-queries.ts @@ -0,0 +1,29 @@ +import { useQuery } from "@tanstack/react-query"; +import type { + SystemMachineProvider, + SystemMachineProvidersQuery, +} from "@bb/server-contract"; +import { sdk } from "@/lib/sdk"; +import { SERVER_SESSION_QUERY_POLICY } from "./query-policies"; + +const SYSTEM_MACHINE_PROVIDERS_QUERY_KEY = "systemMachineProviders"; +const NO_MACHINE_PROVIDERS: readonly SystemMachineProvider[] = []; + +export function systemMachineProvidersQueryKey( + query: SystemMachineProvidersQuery = {}, +) { + return [SYSTEM_MACHINE_PROVIDERS_QUERY_KEY, query.projectId ?? null] as const; +} + +export function useSystemMachineProviders( + query: SystemMachineProvidersQuery = {}, +): { providers: readonly SystemMachineProvider[] | undefined } { + const result = useQuery({ + queryKey: systemMachineProvidersQueryKey(query), + queryFn: () => sdk.hosts.listProviders(query), + ...SERVER_SESSION_QUERY_POLICY, + }); + return { + providers: result.isError ? NO_MACHINE_PROVIDERS : result.data, + }; +} diff --git a/apps/app/src/hooks/useLocalPathPicker.tsx b/apps/app/src/hooks/useLocalPathPicker.tsx index aba734cdd4..377826a94e 100644 --- a/apps/app/src/hooks/useLocalPathPicker.tsx +++ b/apps/app/src/hooks/useLocalPathPicker.tsx @@ -4,7 +4,7 @@ import type { HostPlatform } from "@bb/host-daemon-contract"; import { useDialogState } from "@/hooks/useDialogState"; import { useHostDaemon } from "@/hooks/useHostDaemon"; import { - selectPersistentHosts, + selectHosts, useHosts, usePrimaryHost, } from "@/hooks/queries/host-queries"; @@ -75,7 +75,7 @@ export function useLocalPathPicker({ usePathPickerHost(); const hostsQuery = useHosts(); const isLoadingHosts = hostsQuery.isPending; - const connectedHostCount = selectPersistentHosts(hostsQuery.data).filter( + const connectedHostCount = selectHosts(hostsQuery.data).filter( (host) => host.status === "connected", ).length; const projectPathDialog = useDialogState(); diff --git a/apps/app/src/hooks/useQuickCreateProject.test.tsx b/apps/app/src/hooks/useQuickCreateProject.test.tsx index 2aff247879..0ce3c035c7 100644 --- a/apps/app/src/hooks/useQuickCreateProject.test.tsx +++ b/apps/app/src/hooks/useQuickCreateProject.test.tsx @@ -28,9 +28,8 @@ vi.mock("@/hooks/mutations/project-mutations", () => ({ useCreateProject: () => ({ isPending: false, mutate: mocks.mutate }), })); -vi.mock("@/hooks/queries/host-queries", () => ({ - selectPersistentHosts: (hosts: readonly Host[] | undefined) => - hosts ? [...hosts] : [], +vi.mock("@/hooks/queries/host-queries", async (importOriginal) => ({ + ...(await importOriginal()), useHosts: () => ({ data: mocks.hosts, isPending: mocks.isLoadingHosts }), })); @@ -88,13 +87,22 @@ describe("useQuickCreateProject", () => { expect(mocks.openPathEntry).toHaveBeenCalledWith({ kind: "create" }); }); - it("exposes the machine list for the dialog's picker", () => { - mocks.hosts = [host("host_atum", "atum"), host("host_thoth", "Thoth")]; + it("exposes every machine for the dialog's picker", () => { + mocks.hosts = [ + host("host_atum", "atum"), + host("host_thoth", "Thoth"), + makeHost({ + id: "host_sandbox", + name: "Sandbox", + machineProviderId: "modal-sandbox", + }), + ]; const { result } = renderHook(() => useQuickCreateProject()); expect(result.current.hosts.map((item) => item.id)).toEqual([ "host_atum", "host_thoth", + "host_sandbox", ]); }); }); diff --git a/apps/app/src/hooks/useQuickCreateProject.tsx b/apps/app/src/hooks/useQuickCreateProject.tsx index 0c1845b47b..f73e31115e 100644 --- a/apps/app/src/hooks/useQuickCreateProject.tsx +++ b/apps/app/src/hooks/useQuickCreateProject.tsx @@ -9,7 +9,7 @@ import { useLocation, useNavigate } from "react-router-dom"; import { deriveProjectNameFromPath, type Host } from "@bb/domain"; import type { HostPlatform } from "@bb/host-daemon-contract"; import { useCreateProject } from "@/hooks/mutations/project-mutations"; -import { selectPersistentHosts, useHosts } from "@/hooks/queries/host-queries"; +import { selectHosts, useHosts } from "@/hooks/queries/host-queries"; import { useLocalPathPicker, type LocalPathSubmitParams, @@ -48,10 +48,7 @@ const quickCreateProjectContext = export function useQuickCreateProject(): QuickCreateProjectController { const { mutate, isPending } = useCreateProject(); const hostsQuery = useHosts(); - const hosts = useMemo( - () => selectPersistentHosts(hostsQuery.data), - [hostsQuery.data], - ); + const hosts = useMemo(() => selectHosts(hostsQuery.data), [hostsQuery.data]); const navigate = useNavigate(); const location = useLocation(); const setRootComposeProjectId = useSetRootComposeProjectId(); diff --git a/apps/app/src/hooks/useThreadCreationOptions.test.tsx b/apps/app/src/hooks/useThreadCreationOptions.test.tsx index 5c07527fac..44fa314630 100644 --- a/apps/app/src/hooks/useThreadCreationOptions.test.tsx +++ b/apps/app/src/hooks/useThreadCreationOptions.test.tsx @@ -837,7 +837,6 @@ describe("useThreadCreationOptions", () => { { id: "capped-host", name: "capped", - type: "persistent", status: "connected", maxPermissionMode: "accept-edits", lastSeenAt: null, diff --git a/apps/app/src/lib/plugin-slots.ts b/apps/app/src/lib/plugin-slots.ts index 9d56b2b842..c887878ce3 100644 --- a/apps/app/src/lib/plugin-slots.ts +++ b/apps/app/src/lib/plugin-slots.ts @@ -4,6 +4,7 @@ import type { ExperimentalAppOverlayRegistration, PluginDiffRendererRegistration, PluginEnvironmentProviderInputsRegistration, + PluginMachineProviderInputsRegistration, PluginPendingInteractionRegistration, PluginFileOpenerRegistration, PluginHomepageSectionRegistration, @@ -53,6 +54,7 @@ export interface PluginRegistrationSet { providerIcons?: readonly PluginProviderIconRegistration[]; timelineRenderers?: readonly PluginTimelineRendererRegistration[]; environmentProviderInputs?: readonly PluginEnvironmentProviderInputsRegistration[]; + machineProviderInputs?: readonly PluginMachineProviderInputsRegistration[]; } interface PluginSlotBase { @@ -102,6 +104,8 @@ export interface PluginTimelineRendererSlot extends PluginTimelineRendererRegistration, PluginSlotBase {} export interface PluginEnvironmentProviderInputsSlot extends PluginEnvironmentProviderInputsRegistration, PluginSlotBase {} +export interface PluginMachineProviderInputsSlot + extends PluginMachineProviderInputsRegistration, PluginSlotBase {} export interface PluginSlotSnapshot { homepageSections: readonly PluginHomepageSectionSlot[]; @@ -125,6 +129,7 @@ export interface PluginSlotSnapshot { providerIcons: readonly PluginProviderIconSlot[]; timelineRenderers: readonly PluginTimelineRendererSlot[]; environmentProviderInputs: readonly PluginEnvironmentProviderInputsSlot[]; + machineProviderInputs: readonly PluginMachineProviderInputsSlot[]; } export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { @@ -149,6 +154,7 @@ export const EMPTY_PLUGIN_SLOT_SNAPSHOT: PluginSlotSnapshot = { providerIcons: [], timelineRenderers: [], environmentProviderInputs: [], + machineProviderInputs: [], }; const registrationsByPluginId = new Map(); @@ -180,6 +186,7 @@ const SLOT_KINDS: readonly SlotKind[] = [ "providerIcons", "timelineRenderers", "environmentProviderInputs", + "machineProviderInputs", ]; type FlattenedPluginSlots = { @@ -235,6 +242,7 @@ function flattenRegistrations( providerIcons: stamp(set.providerIcons), timelineRenderers: stamp(set.timelineRenderers), environmentProviderInputs: stamp(set.environmentProviderInputs), + machineProviderInputs: stamp(set.machineProviderInputs), }; } diff --git a/apps/app/src/lib/system-config-atoms.ts b/apps/app/src/lib/system-config-atoms.ts index 529bd45064..8fb335acd9 100644 --- a/apps/app/src/lib/system-config-atoms.ts +++ b/apps/app/src/lib/system-config-atoms.ts @@ -18,6 +18,16 @@ import { import { wsManager } from "./ws"; const unavailableSystemConfig: SystemConfigResponse = { + machineGit: { + status: "not configured", + statusMessage: "Server is unavailable", + }, + serverAccess: { + providers: [], + defaultProviderId: null, + 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..df7a5e40c7 100644 --- a/apps/app/src/test/fixtures/system-config.ts +++ b/apps/app/src/test/fixtures/system-config.ts @@ -11,6 +11,16 @@ export function makeSystemConfig( overrides: Partial = {}, ): SystemConfigResponse { return { + machineGit: { + status: "not configured", + statusMessage: "Server is unavailable", + }, + serverAccess: { + providers: [], + defaultProviderId: null, + effectiveUrl: null, + urlSource: null, + }, generalSettings: defaultAppSettings, keybindings: [], defaultKeybindings: [], diff --git a/apps/app/src/views/MachineSettingsView.test.tsx b/apps/app/src/views/MachineSettingsView.test.tsx index 696547c2f5..769e3874d9 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(), + listProviders: vi.fn(), providerCliStatus: vi.fn(), + resume: vi.fn(), + retryCleanup: vi.fn(), retryUpdate: vi.fn(), + suspend: vi.fn(), update: vi.fn(), }, providers: { list: vi.fn() }, @@ -123,6 +127,7 @@ function renderView() { } function stubSupportingFetches(): void { + vi.mocked(sdk.hosts.listProviders).mockResolvedValue([]); vi.mocked(sdk.hosts.providerCliStatus).mockResolvedValue( providerCliStatusResponse(), ); @@ -155,6 +160,79 @@ afterEach(() => { }); describe("MachineSettingsView", () => { + it("marks a provider-made host with its provider name", async () => { + stubSupportingFetches(); + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([ + host({ + name: "Modal sandbox 3f9a", + machineProviderId: "modal-sandbox", + }), + ]); + vi.mocked(sdk.hosts.listProviders).mockResolvedValue([ + { + id: "modal-sandbox", + displayName: "Modal sandbox", + icon: "./modal-logo.svg", + logoUrl: "/api/v1/system/providers/machine%3Amodal-sandbox/logo?h=hash", + pluginId: "environment-modal-sandbox", + requires: { gitRemote: true }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: true, + environmentRow: null, + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 1_000, + }, + availability: { status: "available" }, + }, + ]); + renderView(); + + const badge = await screen.findByText("Modal sandbox"); + expect( + badge.parentElement?.querySelector("[data-provider-logo]"), + ).not.toBeNull(); + expect(screen.getByRole("button", { name: "Suspend" })).toBeDefined(); + }); + + it("hides lifecycle controls when the provider does not support them", async () => { + stubSupportingFetches(); + vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); + vi.mocked(sdk.hosts.list).mockResolvedValue([ + host({ machineProviderId: "test-machine" }), + ]); + vi.mocked(sdk.hosts.listProviders).mockResolvedValue([ + { + id: "test-machine", + displayName: "Test machine", + icon: null, + logoUrl: null, + pluginId: "test-machine-provider", + requires: { gitRemote: false }, + inputs: null, + acceptsEmptyInputs: true, + supportsSuspend: false, + environmentRow: null, + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 1_000, + }, + availability: { status: "available" }, + }, + ]); + renderView(); + + await screen.findByRole("heading", { name: /dev-vm/u }); + expect(screen.queryByText("Test machine")).toBeNull(); + expect(screen.queryByText("Active")).toBeNull(); + expect(screen.queryByRole("button", { name: "Suspend" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Resume" })).toBeNull(); + }); + it("renders the machine's permission limit as a checked radio with descriptions", async () => { vi.mocked(sdk.system.config).mockResolvedValue(systemConfig()); vi.mocked(sdk.hosts.list).mockResolvedValue([ diff --git a/apps/app/src/views/MachineSettingsView.tsx b/apps/app/src/views/MachineSettingsView.tsx index ed47a00ff8..4f47be9c61 100644 --- a/apps/app/src/views/MachineSettingsView.tsx +++ b/apps/app/src/views/MachineSettingsView.tsx @@ -1,3 +1,5 @@ +import { MachineLifecycleNotice } from "@/components/machines/MachineLifecycleNotice"; +import { MachineProviderDetails } from "@/components/machines/MachineProviderDetails"; import { useMemo, useState, type ReactNode } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; import type { Host, PermissionMode } from "@bb/domain"; @@ -11,6 +13,8 @@ import { Pill } from "@bb/shared-ui/pill"; import { ResourceOverflowMenu } from "@bb/shared-ui/resource-list"; import { ConfirmDeleteDialog } from "@/components/dialogs/ConfirmDeleteDialog"; import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; +import { MachinePhaseBadge } from "@/components/machines/MachinePhaseBadge"; +import { MachineProviderIcon } from "@/components/plugin/MachineProviderIcon"; import { PageShell } from "@/components/ui/page-shell.js"; import { SettingsBadge, @@ -23,10 +27,14 @@ 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, @@ -75,6 +83,7 @@ function headerMeta({ platformLabel: string | null; now: number; }): string { + if (host.lifecycle.progress !== null) return host.lifecycle.progress; const parts: string[] = [host.status === "connected" ? "Online" : "Offline"]; if (host.status !== "connected" && host.lastSeenAt !== null) { parts.push( @@ -168,6 +177,7 @@ 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(); @@ -175,6 +185,9 @@ export function MachineSettingsView() { 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); @@ -186,6 +199,12 @@ export function MachineSettingsView() { 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 ?? []; @@ -286,6 +305,18 @@ export function MachineSettingsView() { {showMachineIdentityBadges && isPrimary ? ( Primary ) : null} + + {machineProvider?.icon == null ? null : ( + + + + {machineProvider.displayName} + + + )}
@@ -294,19 +325,60 @@ export function MachineSettingsView() {

- { - renameHost.reset(); - setRenameOpen(true); +
+ {machineProvider?.supportsSuspend && + host.lifecycle.phase === "active" ? ( + + ) : null} + {machineProvider?.supportsSuspend && + host.lifecycle.phase === "suspended" ? ( + + ) : null} + {host.lifecycle.phase === "retiring" && + host.lifecycle.teardown?.status === "failed" ? ( + + ) : null} + { + renameHost.reset(); + setRenameOpen(true); + }, }, - }, - ]} - /> + ]} + /> +
@@ -400,6 +472,15 @@ export function MachineSettingsView() { + {host.machineProviderId ? ( + <> + + setRemoveOpen(true)} + /> + + ) : null} @@ -497,6 +578,7 @@ export function MachineSettingsView() { /> { if (!open && !removeHost.isPending) setRemoveOpen(false); @@ -505,9 +587,11 @@ export function MachineSettingsView() { 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. + This revokes {host.name}'s access to this server. + {host.machineProviderId !== null && + host.machineProviderId !== "manual" + ? "This deletes the managed compute and saved snapshots. Its environments remain as read-only history." + : "Project checkouts stay on its disk, but its environments become read-only history and it cannot run new work until paired again."} {removeHost.isError ? ( diff --git a/apps/app/src/views/ProjectSettingsView.tsx b/apps/app/src/views/ProjectSettingsView.tsx index fc25e0194a..13181422ec 100644 --- a/apps/app/src/views/ProjectSettingsView.tsx +++ b/apps/app/src/views/ProjectSettingsView.tsx @@ -1,4 +1,3 @@ -import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { useCallback, useMemo, useState } from "react"; import { useParams } from "react-router-dom"; import "@bb/shared-ui/icon-extended"; @@ -24,6 +23,7 @@ import { ProjectSourceDeleteDialog, type ProjectSourceDeleteDialogTarget, } from "@/components/dialogs/ProjectSourceDeleteDialog"; +import { MachineStatusDot } from "@/components/machines/MachineStatusDot"; import { SettingsRowList, SettingsSection, @@ -38,7 +38,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 { useLocalPathPicker, type LocalPathSubmitParams, @@ -61,10 +61,7 @@ export function ProjectSettingsView() { useState(null); const hostsQuery = useHosts(); - const hosts = useMemo( - () => selectPersistentHosts(hostsQuery.data), - [hostsQuery.data], - ); + const hosts = useMemo(() => selectHosts(hostsQuery.data), [hostsQuery.data]); const deleteSource = useDeleteLocalProjectSource(); const addLocalSource = useAddLocalProjectSource(); diff --git a/apps/app/src/views/SettingsView.tsx b/apps/app/src/views/SettingsView.tsx index a42cdafe78..04ca5b7da6 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, @@ -1274,6 +1276,8 @@ export function SettingsView() { } else { content = ( <> + + { }); describe("ThreadDetailPromptArea", () => { + it("shows the transient command while a new manual machine keeps its thread starting", async () => { + const readCommand = vi + .spyOn(sdk.hosts, "experimental_enrollmentCommand") + .mockResolvedValue({ + command: "bb machine enroll --bootstrap-env PRIVATE_MANUAL_BUNDLE", + }); + try { + const thread = makeThread({ + environmentId: null, + status: "starting", + runtime: { + displayStatus: "starting", + hostReconnectGraceExpiresAt: null, + }, + }); + const view = renderPromptArea({ thread }); + expect( + await screen.findByText( + "bb machine enroll --bootstrap-env PRIVATE_MANUAL_BUNDLE", + ), + ).toBeTruthy(); + expect(readCommand).toHaveBeenCalledWith({ + id: thread.id, + scope: "thread", + signal: expect.any(AbortSignal), + }); + view.rerender( + buildPromptAreaElement({ + thread: makeThread({ + status: "idle", + runtime: { + displayStatus: "idle", + hostReconnectGraceExpiresAt: null, + }, + }), + }), + ); + expect( + screen.queryByText( + "bb machine enroll --bootstrap-env PRIVATE_MANUAL_BUNDLE", + ), + ).toBeNull(); + } finally { + readCommand.mockRestore(); + } + }); + it("shows queued work while its message details are loading", () => { mocks.queuedMessages = undefined; diff --git a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx index 9f229783d8..d4b71918b3 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailPromptArea.tsx @@ -1,3 +1,4 @@ +import { MachineEnrollmentCommand } from "@/components/dialogs/MachineEnrollmentCommand"; import { useCallback, useEffect, @@ -1723,6 +1724,11 @@ export function ThreadDetailPromptArea({ return ( <> + {(runtimeDisplayStatus === "provisioning" || + runtimeDisplayStatus === "starting") && + thread.archivedAt === null ? ( + + ) : null} {sentMessageEditorPortal} {bottomContent} diff --git a/apps/app/src/views/thread-detail/ThreadDetailView.tsx b/apps/app/src/views/thread-detail/ThreadDetailView.tsx index 24bf935384..a774b1a3d9 100644 --- a/apps/app/src/views/thread-detail/ThreadDetailView.tsx +++ b/apps/app/src/views/thread-detail/ThreadDetailView.tsx @@ -100,7 +100,7 @@ import { assertNever } from "@bb/thread-view"; import { useCreateThreadInEnvironment } from "@/hooks/useCreateThreadInEnvironment"; import { useHostDaemon } from "@/hooks/useHostDaemon"; import { useLocalOpenTargets } from "@/hooks/useLocalOpenTargets"; -import { selectPersistentHosts, useHosts } from "@/hooks/queries/host-queries"; +import { selectHosts, useHosts } from "@/hooks/queries/host-queries"; import { useSystemConfig } from "@/hooks/queries/system-queries"; import { useConnectionAwareQueryState } from "@/hooks/queries/connection-aware-query-state"; import { @@ -936,7 +936,7 @@ function ThreadDetailViewInternal(props: ThreadRoutePathArgs) { if (!environmentHostId) return null; return hosts.find((host) => host.id === environmentHostId) ?? null; }, [environment?.hostId, hostsQuery.data]); - const hasMultipleMachines = selectPersistentHosts(hostsQuery.data).length > 1; + const hasMultipleMachines = selectHosts(hostsQuery.data).length > 1; const threadEnvironmentHost = shouldShowEnvironmentHostIdentity( hasMultipleMachines, thread?.projectId === PERSONAL_PROJECT_ID, 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..d287ffe074 --- /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", "--secret", "--json"], + register, + ); + expect(collectLogPayloads(vi.mocked(console.error))).toEqual([]); + expect(await requests[0].json()).toEqual({ + name: "GH_TOKEN", + value: "cli-secret", + secret: true, + note: null, + }); + expect(requests[0].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([ + "PUT", + "GET", + "DELETE", + ]); + expect(requests[2].url).toBe( + "http://server/api/v1/settings/machine-environment/GH_TOKEN", + ); + 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..6ef54737e3 100644 --- a/apps/cli/src/__tests__/command-output/machine.test.ts +++ b/apps/cli/src/__tests__/command-output/machine.test.ts @@ -13,12 +13,31 @@ import { resolveMachineId, } from "../../commands/machine.js"; +const launch = { + id: "retry-1", + phase: "ready", + hostId: "host-remote", + step: "Connected", + log: "", + message: null, + cancelPending: false, + terminal: true, +}; + const hosts: Host[] = [ { id: "host-primary", name: "workstation", - type: "persistent", status: "connected", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: 1_700_000_000_000, lastRejectedProtocolVersion: null, @@ -28,8 +47,16 @@ const hosts: Host[] = [ { id: "host-remote", name: "laptop", - type: "persistent", status: "disconnected", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, @@ -44,6 +71,238 @@ describe("bb machine command output", () => { const register: CommandRegistrar = (program) => registerMachineCommands(program, () => "http://server"); + it("creates with a stable key, JSON inputs, and a project resolved by name", async () => { + const create = vi.fn(async () => launch); + const projects = vi.fn(async () => [{ id: "project-1", name: "Example" }]); + stubServerApi({ + "v1.hosts.launches.:id.$get": vi.fn(async () => launch), + "v1.hosts.:id.$get": vi.fn(async () => hosts[1]), + "v1.hosts.$post": create, + "v1.projects.$get": projects, + }); + + await runCommand( + [ + "machine", + "create", + "--provider", + "ssh", + "--key", + "retry-1", + "--inputs", + '{"address":"example.test"}', + "--project", + "Example", + "--json", + ], + register, + ); + + expect(create).toHaveBeenCalledWith( + { + json: { + machineProviderId: "ssh", + key: "retry-1", + projectId: "project-1", + inputs: { address: "example.test" }, + }, + }, + { init: { signal: expect.any(AbortSignal) } }, + ); + expect(JSON.parse(collectLogPayloads(vi.mocked(console.log))[0])).toEqual( + hosts[1], + ); + }); + + it("follows transient launch failures until the server reaches ready", async () => { + const poll = vi + .fn() + .mockResolvedValueOnce({ + ...launch, + phase: "failed", + hostId: null, + terminal: false, + message: "temporary vendor failure", + }) + .mockResolvedValueOnce(launch); + stubServerApi({ + "v1.hosts.launches.:id.$get": poll, + "v1.hosts.:id.$get": vi.fn(async () => hosts[1]), + "v1.hosts.$post": vi.fn(async () => launch), + }); + await runCommand(["machine", "create", "--provider", "ssh"], register); + expect(poll).toHaveBeenCalledTimes(2); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ + "Machine host-remote created", + ]); + }); + + it.each([ + { provider: "ssh", inputs: null, argv: [] }, + { provider: "digitalocean", inputs: {}, argv: ["--inputs", "{}"] }, + ])("creates $provider globally without a project and lets the server choose the key", async ({ provider, inputs, argv }) => { + const create = vi.fn(async () => launch); + stubServerApi({ + "v1.hosts.launches.:id.$get": vi.fn(async () => launch), + "v1.hosts.:id.$get": vi.fn(async () => hosts[1]), + "v1.hosts.$post": create, + }); + + await runCommand(["machine", "create", "--provider", provider, ...argv], register); + + expect(create).toHaveBeenCalledWith( + { + json: { machineProviderId: provider, projectId: null, inputs }, + }, + { init: { signal: expect.any(AbortSignal) } }, + ); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ + "Machine host-remote created", + ]); + }); + + it("returns the launch ID without polling with --no-wait", async () => { + const poll = vi.fn(async () => launch); + stubServerApi({ + "v1.hosts.$post": vi.fn(async () => launch), + "v1.hosts.launches.:id.$get": poll, + }); + await runCommand( + ["machine", "create", "--provider", "ssh", "--no-wait", "--json"], + register, + ); + expect(poll).not.toHaveBeenCalled(); + expect(JSON.parse(collectLogPayloads(vi.mocked(console.log))[0])).toEqual( + launch, + ); + }); + + it.each([true, false])( + "prints manual credentials only from the transient endpoint (no-wait=%s)", + async (noWait) => { + const command = "bb machine enroll --bootstrap-env TRANSIENT_SECRET"; + const readCommand = vi.fn(async () => ({ command })); + stubServerApi({ + "v1.hosts.$post": vi.fn(async () => ({ + ...launch, + phase: "creating", + terminal: false, + step: "Run the enrollment command shown in the picker", + })), + "v1.hosts.launches.:id.enrollment-command.$get": readCommand, + "v1.hosts.launches.:id.$get": vi.fn(async () => launch), + "v1.hosts.:id.$get": vi.fn(async () => hosts[1]), + }); + await runCommand( + [ + "machine", + "create", + "--provider", + "manual", + ...(noWait ? ["--no-wait", "--json"] : []), + ], + register, + ); + expect(readCommand).toHaveBeenCalledWith( + { param: { id: launch.id }, query: { scope: undefined } }, + { init: { signal: expect.any(AbortSignal) } }, + ); + if (noWait) { + const result = JSON.parse( + collectLogPayloads(vi.mocked(console.log))[0], + ); + expect(result.command).toBe(command); + expect(result.step).not.toContain("TRANSIENT_SECRET"); + } else + expect(collectLogPayloads(vi.mocked(console.error))).toContain(command); + }, + ); + + it("cancels only through the explicit launch cancellation endpoint", async () => { + const cancel = vi.fn(async () => ({ ...launch, phase: "cancelled" })); + stubServerApi({ "v1.hosts.launches.:id.cancel.$post": cancel }); + await runCommand(["machine", "cancel", "retry-1", "--json"], register); + expect(cancel).toHaveBeenCalledWith({ param: { id: "retry-1" } }); + }); + + it("rejects malformed JSON without submitting or echoing provider inputs", async () => { + const create = vi.fn(async () => launch); + stubServerApi({ + "v1.hosts.launches.:id.$get": vi.fn(async () => launch), + "v1.hosts.:id.$get": vi.fn(async () => hosts[1]), + "v1.hosts.$post": create, + }); + + await expect( + runCommand( + [ + "machine", + "create", + "--provider", + "ssh", + "--inputs", + '{"credential":"secret"', + ], + register, + ), + ).rejects.toThrow("process.exit:1"); + + expect(create).not.toHaveBeenCalled(); + expect(collectLogPayloads(vi.mocked(console.error))).toEqual([ + "Error: --inputs must be valid JSON.", + ]); + }); + + it("refuses ambiguous project names before creating", async () => { + const create = vi.fn(async () => launch); + stubServerApi({ + "v1.hosts.$post": create, + "v1.projects.$get": vi.fn(async () => [ + { id: "project-1", name: "Example" }, + { id: "project-2", name: "Example" }, + ]), + }); + + await expect( + runCommand( + ["machine", "create", "--provider", "ssh", "--project", "Example"], + register, + ), + ).rejects.toThrow("process.exit:1"); + + expect(create).not.toHaveBeenCalled(); + expect(collectLogPayloads(vi.mocked(console.error))).toEqual([ + "Error: Project name is ambiguous; use its ID.", + ]); + }); + + it("aborts the create request on SIGINT and removes its signal listener", async () => { + const listeners = process.listenerCount("SIGINT"); + const create = vi.fn( + async (_request: object, options: { init: { signal: AbortSignal } }) => { + process.emit("SIGINT"); + expect(options.init.signal.aborted).toBe(true); + throw new Error("remote error containing sensitive input"); + }, + ); + stubServerApi({ + "v1.hosts.launches.:id.$get": vi.fn(async () => launch), + "v1.hosts.:id.$get": vi.fn(async () => hosts[1]), + "v1.hosts.$post": create, + }); + + await expect( + runCommand(["machine", "create", "--provider", "ssh"], register), + ).rejects.toThrow("process.exit:130"); + + expect(create).toHaveBeenCalledOnce(); + expect(process.listenerCount("SIGINT")).toBe(listeners); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([]); + expect(collectLogPayloads(vi.mocked(console.error))).toEqual([ + "Error: Stopped following; creation continues. Use bb machine cancel to cancel.", + ]); + }); + it("bb machine list --json prints the raw host list", async () => { stubServerApi({ "v1.hosts.$get": vi.fn(async () => hosts) }); @@ -62,7 +321,7 @@ describe("bb machine command output", () => { expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ "", - "Name ID Status Last seen\n----------- ------------ ------------ ---------\nworkstation host-primary connected 2m ago\n----------- ------------ ------------ ---------\nlaptop host-remote disconnected never", + "Name ID Status Provider Last seen\n----------- ------------ ------------ ------------- ---------\nworkstation host-primary connected user-enrolled 2m ago\n----------- ------------ ------------ ------------- ---------\nlaptop host-remote disconnected user-enrolled never", "", ]); }); @@ -81,6 +340,67 @@ 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 () => ({ ok: true 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("bb machine providers evaluates providers for the requested project", async () => { + const listProviders = vi.fn(async () => ({ + providers: [ + { + id: "modal-sandbox", + displayName: "Modal sandbox", + availability: { status: "available" }, + }, + ], + })); + stubServerApi({ "v1.system.machine-providers.$get": listProviders }); + + await runCommand(["machine", "providers", "--project", "proj-1"], register); + + expect(listProviders).toHaveBeenCalledWith({ + query: { projectId: "proj-1" }, + }); + expect(collectLogPayloads(vi.mocked(console.log))).toEqual([ + "modal-sandbox Modal sandbox available", + ]); + }); + + 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..8a1996fd83 100644 --- a/apps/cli/src/__tests__/command-output/settings.test.ts +++ b/apps/cli/src/__tests__/command-output/settings.test.ts @@ -139,7 +139,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 888379ddb1..50e3b394e8 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, @@ -1283,4 +1280,96 @@ describe("bb thread spawn command output", () => { expect(post).not.toHaveBeenCalled(); }); }); + + it("creates a provider machine and its picker-sugar environment", async () => { + const post = vi.fn(async () => + fixtures.makeThread({ + id: "thread-new-machine", + projectId: "proj-1", + providerId: "codex", + }), + ); + stubServerApi({ + "v1.threads.$post": post, + "v1.system.machine-providers.$get": vi.fn(async () => ({ + providers: [ + { + id: "test-machine", + displayName: "Test machine", + icon: null, + logoUrl: null, + pluginId: "test-machine-provider", + requires: { gitRemote: false }, + inputs: { + type: "object", + properties: { target: { type: "string" } }, + required: ["target"], + }, + acceptsEmptyInputs: false, + environmentRow: { + displayName: "Test machine", + environmentProviderId: "project-checkout", + }, + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 30_000, + }, + availability: null, + }, + ], + })), + "v1.system.environment-providers.$get": vi.fn(async () => ({ + providers: [ + { + id: "project-checkout", + displayName: "Project checkout", + icon: null, + logoUrl: null, + pluginId: "environment-project-checkout", + acceptsEmptyInputs: true, + availability: null, + requires: { + projectCheckout: true, + gitCheckout: true, + gitRemote: false, + projectless: false, + }, + inputs: null, + }, + ], + })), + }); + + await runCommand( + [ + "thread", + "spawn", + "--project", + "proj-1", + "--prompt", + "hello", + "--new-machine", + "test-machine", + "--machine-inputs", + '{"target":"buildbox"}', + ], + register, + ); + + expect(post).toHaveBeenCalledWith({ + json: expect.objectContaining({ + environment: { + type: "provider", + environmentProviderId: "project-checkout", + machine: { + type: "new", + machineProviderId: "test-machine", + inputs: { target: "buildbox" }, + }, + inputs: null, + }, + }), + }); + }); }); diff --git a/apps/cli/src/__tests__/command-output/updates.test.ts b/apps/cli/src/__tests__/command-output/updates.test.ts index 2692b97a66..4072164a64 100644 --- a/apps/cli/src/__tests__/command-output/updates.test.ts +++ b/apps/cli/src/__tests__/command-output/updates.test.ts @@ -15,7 +15,15 @@ const hosts: Host[] = [ id: "host-primary", name: "workstation", status: "connected", - type: "persistent", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: 1_700_000_000_000, lastRejectedProtocolVersion: null, @@ -26,7 +34,15 @@ const hosts: Host[] = [ id: "host-remote", name: "laptop", status: "disconnected", - type: "persistent", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, diff --git a/apps/cli/src/__tests__/plugin-cli-proxy.test.ts b/apps/cli/src/__tests__/plugin-cli-proxy.test.ts index e7efb08612..fc0c6a45be 100644 --- a/apps/cli/src/__tests__/plugin-cli-proxy.test.ts +++ b/apps/cli/src/__tests__/plugin-cli-proxy.test.ts @@ -455,6 +455,57 @@ describe("runPluginCliCommand", () => { vi.unstubAllGlobals(); }); + it("transfers multiline stdin and prints each continuation page before requesting the next", async () => { + const requests: string[][] = []; + const writes: string[] = []; + const output = { + write(value: string, callback: (error?: Error | null) => void) { + writes.push(value); + callback(); + return true; + }, + }; + vi.stubGlobal( + "fetch", + vi.fn(async (_url, init: RequestInit | undefined) => { + requests.push(JSON.parse(String(init?.body)).argv); + if (requests.length === 1) + return new Response( + JSON.stringify({ + exitCode: 0, + stdout: "page 1", + experimental_continue: { + argv: ["logs", "--cursor", "12"], + delayMs: 0, + }, + }), + ); + expect(writes).toEqual(["page 1\n"]); + return new Response(JSON.stringify({ exitCode: 0, stdout: "page 2" })); + }), + ); + const input = { + isTTY: false, + async *[Symbol.asyncIterator]() { + yield "RUN echo one\nRUN echo two\n"; + }, + }; + expect( + await runPluginCliCommand( + "http://localhost", + "fixture", + ["put", "--stdin"], + { stdout: output, stderr: output }, + input, + ), + ).toBe(0); + expect(requests).toEqual([ + ["put", "--input-text", "RUN echo one\nRUN echo two\n"], + ["logs", "--cursor", "12"], + ]); + expect(writes).toEqual(["page 1\n", "page 2\n"]); + }); + it("waits for output larger than 64 KiB to flush before returning", async () => { const stdout = "x".repeat(1024 * 1024); vi.stubGlobal( 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..43bce37ab5 --- /dev/null +++ b/apps/cli/src/commands/machine-enrollment.test.ts @@ -0,0 +1,236 @@ +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 = () => ({ + version: 2, + 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); + await writeFile(join(h.dir, "enrollment.lock"), "stale-lock"); + 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("recovers a dead process lock and refuses a live owner", async () => { + const h = await harness(); + await writeFile(join(h.dir, "enrollment.lock"), String(process.pid)); + await expect(h.run()).rejects.toThrow("holds the local identity lock"); + expect(h.fetchFn).not.toHaveBeenCalled(); + await writeFile(join(h.dir, "enrollment.lock"), "2147483647"); + h.env.BB_ENROLLMENT = JSON.stringify(bundle()); + await expect(h.run()).resolves.toEqual({ hostId: "host_test" }); + }); + + 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(), expiresAt: 1 }); + await expect(h.run()).rejects.toThrow("expired"); + expect(h.fetchFn).not.toHaveBeenCalled(); + }); + + it("suppresses secret-bearing remote errors and releases the identity lock", 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.each([1, 2])( + "accepts delivered v%s direct and Connect bundles from file and environment", + async (version) => { + for (const kind of ["direct", "connect"]) { + for (const source of ["file", "env"]) { + const h = await harness(); + const headers = + kind === "connect" + ? { "x-bb-connect-machine": "private-connect" } + : undefined; + const value = { + ...bundle(), + version, + serverUrl: "https://test.getbb.app", + ...(version === 1 + ? { + client: + kind === "direct" + ? { kind } + : { + kind, + machineCode: "legacy-code", + expiresAt: Date.now() + 60000, + }, + } + : { 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 (url) => + String(url).includes("/redeem-machine") + ? Response.json({ + credential: "private-connect", + serverUrl: value.serverUrl, + }) + : 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.mock.calls.filter(([url]) => + String(url).includes("redeem-machine"), + ), + ).toHaveLength(version === 1 && kind === "connect" ? 1 : 0); + } + } + }, +); + +it("reuses a v1 Connect upgrade after the enrollment response is lost", async () => { + const h = await harness(); + const value = { + ...bundle(), + version: 1, + serverUrl: "https://test.getbb.app", + client: { + kind: "connect", + machineCode: "legacy", + expiresAt: Date.now() + 60000, + }, + }; + h.env.BB_ENROLLMENT = JSON.stringify(value); + h.fetchFn + .mockResolvedValueOnce( + Response.json({ + credential: "private-connect", + serverUrl: value.serverUrl, + }), + ) + .mockRejectedValueOnce(new Error("Response lost")); + await expect(h.run()).rejects.toThrow("Could not exchange"); + h.env.BB_ENROLLMENT = JSON.stringify(value); + await h.run(); + expect( + h.fetchFn.mock.calls.filter(([url]) => + String(url).includes("redeem-machine"), + ), + ).toHaveLength(1); +}); diff --git a/apps/cli/src/commands/machine-enrollment.ts b/apps/cli/src/commands/machine-enrollment.ts new file mode 100644 index 0000000000..bfac92f562 --- /dev/null +++ b/apps/cli/src/commands/machine-enrollment.ts @@ -0,0 +1,394 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { randomUUID } from "node:crypto"; +import { + mkdir, + readFile, + rename, + rm, + writeFile, + symlink, + access, + lstat, +} 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({ + version: z.literal(2), + 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 legacyBootstrapSchema = bootstrapSchema + .omit({ version: true, headers: true }) + .extend({ + version: z.literal(1), + client: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("direct") }), + z.object({ + kind: z.literal("connect"), + machineCode: z.string().min(1), + expiresAt: z.number().positive(), + }), + ]), + }); +const acceptedBootstrapSchema = z.union([ + bootstrapSchema, + legacyBootstrapSchema, +]); +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, home: string): Promise { + const path = join(dataDir, "host-daemon-port"); + if ((await readOptional(path)) !== null) return; + const registry = join(home, ".bb-machines", "host-daemon-ports"); + await mkdir(registry, { recursive: true }); + for (let port = 38888; port <= 65535; port += 1) { + const reservation = join(registry, String(port)); + try { + await mkdir(reservation); + } catch { + continue; + } + const server = createServer(); + let claimed = false; + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "127.0.0.1", resolve); + }); + await atomicWrite(join(reservation, "data-dir"), `${dataDir}\n`); + await atomicWrite(path, `${port}\n`); + claimed = true; + return; + } catch (error) { + if ( + !( + error instanceof Error && + "code" in error && + error.code === "EADDRINUSE" + ) + ) + throw error; + } finally { + if (server.listening) + await new Promise((resolve) => server.close(() => resolve())); + if (!claimed) await rm(reservation, { recursive: true, force: true }); + } + } + throw new Error("No machine daemon port is available"); +} + +async function acquireEnrollmentLock( + path: string, +): Promise<() => Promise> { + async function create(): Promise<() => Promise> { + await writeFile(path, `${process.pid}`, { flag: "wx", mode: 0o600 }); + const owned = await lstat(path); + return async () => { + const current = await lstat(path).catch(() => null); + if (current?.ino === owned.ino && current.dev === owned.dev) + await rm(path); + }; + } + try { + return await create(); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "EEXIST")) + throw new Error("Could not acquire machine identity lock"); + } + const previous = await lstat(path); + if ( + !previous.isFile() || + (process.getuid && previous.uid !== process.getuid()) + ) + throw new Error("Refusing to replace an unowned machine identity lock"); + const owner = await readFile(path, "utf8"); + if (!/^[1-9][0-9]*$/u.test(owner) || !Number.isSafeInteger(Number(owner))) + throw new Error("Machine identity lock owner is invalid"); + try { + process.kill(Number(owner), 0); + throw new Error("Another machine enrollment holds the local identity lock"); + } catch (error) { + if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) + throw new Error( + "Another machine enrollment holds the local identity lock", + ); + } + const current = await lstat(path); + if ( + current.ino !== previous.ino || + current.dev !== previous.dev || + current.mtimeMs !== previous.mtimeMs + ) + throw new Error("Machine identity lock changed; retry enrollment"); + await rm(path); + try { + return await create(); + } catch { + throw new Error( + "Another machine enrollment acquired the local identity lock", + ); + } +} + +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 = acceptedBootstrapSchema.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 }); + const lockPath = join(dataDir, "enrollment.lock"); + const releaseLock = await acquireEnrollmentLock(lockPath); + try { + 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, home); + 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); + if (bootstrap.version === 1) { + await atomicWrite(join(dataDir, "host-id"), `${bootstrap.hostId}\n`); + let headers = config.serverHeaders; + if ( + bootstrap.client.kind === "connect" && + !headers?.["x-bb-connect-machine"] + ) { + if (bootstrap.client.expiresAt <= Date.now()) + throw new Error("Machine access code has expired"); + const base = new URL(serverUrl); + base.hostname = base.hostname.split(".").slice(1).join("."); + const response = await fetchFn( + new URL("/api/connect/redeem-machine", base), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: bootstrap.client.machineCode }), + signal, + }, + ); + if (!response.ok) + throw new Error(`Machine redeem failed (${response.status})`); + const redeemed = z + .object({ credential: z.string().min(1), serverUrl: serverUrlSchema }) + .parse(await response.json()); + if (normalizeUrl(redeemed.serverUrl) !== serverUrl) + throw new Error("Machine access code belongs to a different server"); + headers = { "x-bb-connect-machine": redeemed.credential }; + } + const { client, ...fields } = bootstrap; + bootstrap = { + ...fields, + version: 2, + ...(client.kind === "connect" ? { headers } : {}), + }; + await atomicWrite( + join(dataDir, "config.json"), + `${JSON.stringify({ ...config, serverUrl, serverHeaders: bootstrap.headers })}\n`, + ); + if (options.bootstrapFile) + await atomicWrite( + options.bootstrapFile, + `${JSON.stringify(bootstrap)}\n`, + ); + } + 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 }; + } finally { + await releaseLock(); + } +} diff --git a/apps/cli/src/commands/machine-environment.ts b/apps/cli/src/commands/machine-environment.ts new file mode 100644 index 0000000000..13a146dd05 --- /dev/null +++ b/apps/cli/src/commands/machine-environment.ts @@ -0,0 +1,88 @@ +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("--secret", "Store the value in a private file") + .option("--note ", "Describe this variable") + .option("--json", "Print machine-readable JSON output") + .action( + action( + async ( + name: string, + options: { secret?: boolean; note?: string; json?: boolean }, + ) => { + const result = await createCliBbSdk( + getUrl(), + ).system.setMachineEnvironment({ + name, + value: await readValue(), + secret: options.secret ?? false, + 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 }) => { + printEnvironment( + await createCliBbSdk(getUrl()).system.unsetMachineEnvironment(name), + options, + ); + }), + ); +} diff --git a/apps/cli/src/commands/machine-lifecycle.test.ts b/apps/cli/src/commands/machine-lifecycle.test.ts new file mode 100644 index 0000000000..94c425199c --- /dev/null +++ b/apps/cli/src/commands/machine-lifecycle.test.ts @@ -0,0 +1,318 @@ +import { + mkdir, + mkdtemp, + readFile, + realpath, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { runMachineLifecycle } from "./machine-lifecycle.js"; + +const homes: string[] = []; +afterEach(async () => { + await Promise.all( + homes.splice(0).map((home) => rm(home, { recursive: true, force: true })), + ); +}); + +async function fixture( + platform: NodeJS.Platform = "linux", + withService = true, + system = false, +) { + await mkdir("/tmp/pr2", { recursive: true }); + const homeDir = await realpath(await mkdtemp("/tmp/pr2/machine-lifecycle-")); + homes.push(homeDir); + const dataDir = join(homeDir, ".bb-machines", "owned"); + const launcher = join(dataDir, "npm", "bin", "bb-app"); + await mkdir(join(dataDir, "npm", "bin"), { recursive: true }); + await writeFile(launcher, ""); + await writeFile( + join(dataDir, "auth.json"), + JSON.stringify({ hostId: "host_one" }), + ); + await writeFile( + join(dataDir, "config.json"), + JSON.stringify({ serverUrl: "https://bb.example" }), + ); + await writeFile(join(dataDir, "host-daemon-port"), "44001\n"); + const servicePath = + platform === "darwin" + ? join( + homeDir, + "Library", + "LaunchAgents", + "app.getbb.host-daemon.bb-example-host_one.plist", + ) + : system ? join(dataDir, "systemd", "bb-host-daemon-bb-example-host_one.service") : join( + homeDir, + ".config", + "systemd", + "user", + "bb-host-daemon-bb-example-host_one.service", + ); + await mkdir(join(servicePath, ".."), { recursive: true }); + if (withService) + await writeFile( + servicePath, + platform === "darwin" + ? `BB_DATA_DIR${dataDir}` + : `Environment="BB_DATA_DIR=${dataDir}"\nExecStart="/usr/bin/node" "${launcher}" host-daemon --auto-update --host-daemon-port "44001" --server-url "https://bb.example"`, + ); + const reservation = join( + homeDir, + ".bb-machines", + "host-daemon-ports", + "44001", + ); + await mkdir(reservation, { recursive: true }); + await writeFile(join(reservation, "data-dir"), dataDir); + const calls: string[] = []; + const state = { + active: true, + process: withService + ? "" + : `${launcher} host-daemon --auto-update --host-daemon-port 44001 --server-url https://bb.example`, + statusHostId: "host_one", + }; + if (!withService) + await writeFile(join(dataDir, "install-daemon.pid"), "1234\n"); + const deps: NonNullable[2]> = { + homeDir, + platform, + uid: system ? 0 : 501, + async run(command, args) { + calls.push([command, ...args].join(" ")); + if (command === "ps") return state.process; + if (args.includes("--property=FragmentPath")) return servicePath; + if ( + args.includes("stop") || + args.includes("disable") || + args.includes("bootout") + ) + state.active = false; + if (args.includes("start") || args.includes("bootstrap")) + state.active = true; + return ""; + }, + status: async () => + state.active + ? { hostId: state.statusHostId, serverUrl: "https://bb.example" } + : null, + kill(pid) { + calls.push(`kill ${pid}`); + state.active = false; + state.process = ""; + }, + async start(command, args, directory) { + calls.push(`start ${command} ${args.join(" ")} ${directory}`); + state.active = true; + return 5678; + }, + sleep: async () => {}, + }; + return { homeDir, dataDir, servicePath, reservation, calls, state, deps }; +} + +const options = { hostId: "host_one" }; +describe("owned local machine lifecycle", () => { + it("starts after reboot, stops, and uninstalls an owned system unit", async () => { + const f = await fixture("linux", true, true); + f.state.active = false; + await runMachineLifecycle("start", options, f.deps); + expect(f.calls).toContain("systemctl --system start bb-host-daemon-bb-example-host_one.service"); + await runMachineLifecycle("stop", options, f.deps); + expect(f.calls).toContain("systemctl --system stop bb-host-daemon-bb-example-host_one.service"); + expect(await readFile(f.servicePath, "utf8")).toContain("BB_DATA_DIR"); + await runMachineLifecycle("start", options, f.deps); + await runMachineLifecycle("uninstall", options, f.deps); + expect(f.calls).toContain("systemctl --system disable --now bb-host-daemon-bb-example-host_one.service"); + expect(f.calls).toContain("systemctl --system daemon-reload"); + await expect(readFile(f.servicePath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("refuses a system service with another server command before stopping it", async () => { + const f = await fixture("linux", true, true); + const text = await readFile(f.servicePath, "utf8"); + await writeFile(f.servicePath, text.replace("https://bb.example", "https://other.example")); + await expect(runMachineLifecycle("uninstall", options, f.deps)).rejects.toThrow("command belongs"); + expect(f.calls).toEqual([]); + }); + it("refuses a system manager unit loaded from another path", async () => { + const f = await fixture("linux", true, true); + const other = join(f.homeDir, "another.service"); + await writeFile(other, "unrelated"); + const run = f.deps.run; + f.deps.run = async (command, args) => + args.includes("--property=FragmentPath") ? other : run(command, args); + await expect(runMachineLifecycle("uninstall", options, f.deps)).rejects.toThrow("loaded another"); + expect(f.calls).toEqual([]); + }); + it("cleans a system unit left before enable without touching another service", async () => { + const f = await fixture("linux", true, true); + f.state.active = false; + const run = f.deps.run; + f.deps.run = async (command, args) => + args.includes("--property=FragmentPath") ? "" : run(command, args); + await runMachineLifecycle("uninstall", options, f.deps); + expect(f.calls).toEqual(["systemctl --system daemon-reload"]); + await expect(readFile(f.servicePath)).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("refuses system service control without root", async () => { + const f = await fixture("linux", true, true); + f.deps.uid = 501; + await expect(runMachineLifecycle("stop", options, f.deps)).rejects.toThrow("requires root"); + expect(f.calls).toEqual([]); + }); + + it.each(["linux", "darwin"] as const)( + "uninstalls only the host-specific %s service", + async (platform) => { + const f = await fixture(platform); + const other = `${f.servicePath}.other`; + await writeFile(other, "unrelated"); + await runMachineLifecycle("uninstall", options, f.deps); + expect(await readFile(other, "utf8")).toBe("unrelated"); + await expect( + readFile(join(f.dataDir, "auth.json")), + ).rejects.toMatchObject({ code: "ENOENT" }); + await expect( + readFile(join(f.reservation, "data-dir")), + ).rejects.toMatchObject({ code: "ENOENT" }); + expect(f.calls.some((call) => call.includes("host_one"))).toBe(true); + await runMachineLifecycle("uninstall", options, f.deps); + }, + ); + it("retains a port reservation owned by another installation", async () => { + const f = await fixture(); + await writeFile(join(f.reservation, "data-dir"), "/other"); + await runMachineLifecycle("uninstall", options, f.deps); + expect(await readFile(join(f.reservation, "data-dir"), "utf8")).toBe( + "/other", + ); + }); + it("refuses a different server before stopping anything", async () => { + const f = await fixture(); + await expect( + runMachineLifecycle( + "uninstall", + { ...options, serverUrl: "https://other.example" }, + f.deps, + ), + ).rejects.toThrow("another server"); + expect(f.calls).toEqual([]); + }); + it("refuses a different host in an explicitly selected directory", async () => { + const f = await fixture(); + await expect( + runMachineLifecycle( + "uninstall", + { hostId: "host_other", dataDir: f.dataDir }, + f.deps, + ), + ).rejects.toThrow("another host"); + expect(f.calls).toEqual([]); + }); + it("refuses the default instance directory", async () => { + const f = await fixture(); + const other = join(f.homeDir, ".bb"); + await mkdir(other); + await writeFile( + join(other, "auth.json"), + JSON.stringify({ hostId: "host_one" }), + ); + await expect( + runMachineLifecycle("uninstall", { ...options, dataDir: other }, f.deps), + ).rejects.toThrow("installer-owned root"); + expect(f.calls).toEqual([]); + }); + it("refuses symlinked machine directories", async () => { + const f = await fixture(); + const alias = join(f.homeDir, ".bb-machines", "alias"); + await symlink(f.dataDir, alias); + await expect( + runMachineLifecycle("uninstall", { ...options, dataDir: alias }, f.deps), + ).rejects.toThrow("installer-owned root"); + expect(f.calls).toEqual([]); + }); + it("refuses duplicate host identities", async () => { + const f = await fixture(); + const other = join(f.homeDir, ".bb-machines", "duplicate"); + await mkdir(other); + await writeFile( + join(other, "auth.json"), + JSON.stringify({ hostId: "host_one" }), + ); + await writeFile( + join(other, "config.json"), + JSON.stringify({ serverUrl: "https://bb.example" }), + ); + await expect( + runMachineLifecycle("uninstall", options, f.deps), + ).rejects.toThrow("multiple"); + expect(f.calls).toEqual([]); + }); + it("refuses service files targeting another data directory", async () => { + const f = await fixture(); + await writeFile(f.servicePath, 'Environment="BB_DATA_DIR=/other"'); + await expect( + runMachineLifecycle("uninstall", options, f.deps), + ).rejects.toThrow("service belongs"); + expect(f.calls).toEqual([]); + }); + it("refuses a reused daemon port", async () => { + const f = await fixture(); + f.state.statusHostId = "host_other"; + await expect( + runMachineLifecycle("uninstall", options, f.deps), + ).rejects.toThrow("port belongs"); + expect(f.calls).toEqual([]); + }); + it("refuses a reused PID without killing it", async () => { + const f = await fixture("linux", false); + f.state.process = "/usr/bin/unrelated"; + await expect( + runMachineLifecycle("uninstall", options, f.deps), + ).rejects.toThrow("PID belongs"); + expect(f.calls.some((call) => call.startsWith("kill"))).toBe(false); + }); + it("stops a verified container daemon and retains identity", async () => { + const f = await fixture("linux", false); + await runMachineLifecycle("stop", options, f.deps); + expect(f.calls).toContain("kill 1234"); + expect(await readFile(join(f.dataDir, "auth.json"), "utf8")).toContain( + "host_one", + ); + expect(await readFile(join(f.reservation, "data-dir"), "utf8")).toBe( + f.dataDir, + ); + }); + it("starts a stopped container daemon using its private installation", async () => { + const f = await fixture("linux", false); + f.state.active = false; + f.state.process = ""; + await runMachineLifecycle("start", options, f.deps); + expect( + f.calls.some((call) => + call.startsWith(`start ${f.dataDir}/npm/bin/bb-app`), + ), + ).toBe(true); + expect(await readFile(join(f.dataDir, "install-daemon.pid"), "utf8")).toBe( + "5678\n", + ); + }); + it("retains files when stopping fails", async () => { + const f = await fixture(); + f.deps.run = async () => { + throw new Error("service manager failure"); + }; + await expect( + runMachineLifecycle("uninstall", options, f.deps), + ).rejects.toThrow("service manager failure"); + expect(await readFile(join(f.dataDir, "auth.json"), "utf8")).toContain( + "host_one", + ); + }); +}); diff --git a/apps/cli/src/commands/machine-lifecycle.ts b/apps/cli/src/commands/machine-lifecycle.ts new file mode 100644 index 0000000000..bab36594c4 --- /dev/null +++ b/apps/cli/src/commands/machine-lifecycle.ts @@ -0,0 +1,436 @@ +import { execFile, spawn } from "node:child_process"; +import { closeSync, openSync } from "node:fs"; +import { + lstat, + readFile, + readdir, + realpath, + rm, + rmdir, + writeFile, +} from "node:fs/promises"; +import { homedir } from "node:os"; +import { basename, dirname, join, resolve } from "node:path"; +import { promisify } from "node:util"; +import type { Command } from "commander"; +import { z } from "zod"; +import { action } from "../action.js"; +import { outputJson } from "./helpers.js"; + +const exec = promisify(execFile); +const identitySchema = z.object({ hostId: z.string().min(1) }); +const configSchema = z.object({ serverUrl: z.url() }); +const statusSchema = z.object({ + hostId: z.string().nullable(), + serverUrl: z.string(), +}); + +interface LifecycleOptions { + hostId: string; + serverUrl?: string; + dataDir?: string; +} + +interface LifecycleRuntime { + homeDir: string; + platform: NodeJS.Platform; + uid: number; + run(command: string, args: string[]): Promise; + status(port: number): Promise; + kill(pid: number): void; + start(command: string, args: string[], dataDir: string): Promise; + sleep(): Promise; +} + +const runtime: LifecycleRuntime = { + homeDir: homedir(), + platform: process.platform, + uid: process.getuid?.() ?? 0, + async run(command, args) { + return (await exec(command, args, { timeout: 15_000 })).stdout; + }, + async status(port) { + try { + const response = await fetch(`http://127.0.0.1:${port}/status`, { + signal: AbortSignal.timeout(1000), + }); + if (!response.ok) + throw new Error(`Daemon status returned HTTP ${response.status}.`); + return await response.json(); + } catch (error) { + if ( + error instanceof TypeError || + (error instanceof Error && error.name === "TimeoutError") + ) + return null; + throw error; + } + }, + kill: (pid) => process.kill(pid, "SIGTERM"), + async start(command, args, dataDir) { + const log = openSync(join(dataDir, "install-daemon.log"), "a", 0o600); + try { + const child = spawn(command, args, { + detached: true, + stdio: ["ignore", log, log], + env: { + ...process.env, + BB_DATA_DIR: dataDir, + BB_APP_NPM_PREFIX: join(dataDir, "npm"), + }, + }); + await new Promise((resolve, reject) => { + child.once("spawn", resolve); + child.once("error", reject); + }); + child.unref(); + if (child.pid === undefined) throw new Error("Daemon did not start."); + return child.pid; + } finally { + closeSync(log); + } + }, + sleep: () => new Promise((resolve) => setTimeout(resolve, 250)), +}; + +async function optionalText(path: string): Promise { + try { + return await readFile(path, "utf8"); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return null; + throw error; + } +} + +function normalizedUrl(value: string): string { + return new URL(value).href.replace(/\/+$/u, ""); +} + +async function installation(options: LifecycleOptions, deps: LifecycleRuntime) { + const root = join(deps.homeDir, ".bb-machines"); + let candidates: string[]; + if (options.dataDir !== undefined) candidates = [resolve(options.dataDir)]; + else { + try { + candidates = (await readdir(root, { withFileTypes: true })) + .filter( + (entry) => + entry.name !== "host-daemon-ports" && + (entry.isDirectory() || entry.isSymbolicLink()), + ) + .map((entry) => join(root, entry.name)); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return null; + throw error; + } + } + const matches: Array<{ dataDir: string; serverUrl: string }> = []; + for (const candidate of candidates) { + const auth = await optionalText(join(candidate, "auth.json")); + if (auth === null) continue; + const identity = identitySchema.parse(JSON.parse(auth)); + if (identity.hostId !== options.hostId) { + if (options.dataDir !== undefined) + throw new Error("Machine data directory belongs to another host."); + continue; + } + const dataDir = await realpath(candidate); + const canonicalRoot = await realpath(root); + if ( + dirname(dataDir) !== canonicalRoot || + basename(dataDir) === "host-daemon-ports" || + (await lstat(candidate)).isSymbolicLink() + ) { + throw new Error( + "Refusing a machine data directory outside its installer-owned root.", + ); + } + const config = configSchema.parse( + JSON.parse(await readFile(join(dataDir, "config.json"), "utf8")), + ); + if ( + options.serverUrl !== undefined && + normalizedUrl(config.serverUrl) !== normalizedUrl(options.serverUrl) + ) { + throw new Error("Machine data directory belongs to another server."); + } + matches.push({ dataDir, serverUrl: config.serverUrl }); + } + if (matches.length > 1) + throw new Error( + "Host identity matches multiple machine installations; specify --data-dir.", + ); + return matches[0] ?? null; +} + +function xmlEscape(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function systemdEscape(value: string): string { + return value + .replaceAll("\\", "\\\\") + .replaceAll('"', '\\"') + .replaceAll("%", "%%"); +} + +export async function runMachineLifecycle( + operation: "start" | "stop" | "uninstall", + options: LifecycleOptions, + deps: LifecycleRuntime = runtime, +): Promise { + if (!/^[A-Za-z0-9_-]+$/u.test(options.hostId)) + throw new Error("Invalid machine host ID."); + const installed = await installation(options, deps); + if (installed === null) { + if (operation === "start") + throw new Error("Machine installation was not found."); + return; + } + const { dataDir, serverUrl } = installed; + const rawPort = ( + await readFile(join(dataDir, "host-daemon-port"), "utf8") + ).trim(); + const port = Number(rawPort); + if ( + !Number.isInteger(port) || + String(port) !== rawPort || + port < 1024 || + port === 38886 || + port === 38887 || + port > 65535 + ) + throw new Error("Refusing an invalid or default daemon port."); + const serverHost = new URL(serverUrl).host.replace(/[^a-zA-Z0-9.-]/gu, "-"); + const slug = `${serverHost}-${options.hostId}`.replaceAll(".", "-"); + const serviceName = + deps.platform === "darwin" + ? `app.getbb.host-daemon.${slug}` + : `bb-host-daemon-${slug}.service`; + let servicePath = + deps.platform === "darwin" + ? join(deps.homeDir, "Library", "LaunchAgents", `${serviceName}.plist`) + : join(deps.homeDir, ".config", "systemd", "user", serviceName); + let systemdScope = "--user"; + const systemServicePath = join(dataDir, "systemd", serviceName); + if ( + deps.platform === "linux" && + (await optionalText(systemServicePath)) !== null + ) { + if (deps.uid !== 0) throw new Error("Machine system service requires root."); + if ((await optionalText(servicePath)) !== null) + throw new Error("Machine has both user and system services."); + if ( + (await realpath(dirname(systemServicePath))) !== dirname(systemServicePath) + ) + throw new Error("Refusing a symlinked machine system service directory."); + servicePath = systemServicePath; + systemdScope = "--system"; + } + const service = await optionalText(servicePath); + if (service !== null) { + const expected = + deps.platform === "darwin" + ? `BB_DATA_DIR${xmlEscape(dataDir)}` + : `Environment="BB_DATA_DIR=${systemdEscape(dataDir)}"`; + if ( + !service.includes(expected) || + (await lstat(servicePath)).isSymbolicLink() + ) + throw new Error("Machine service belongs to another installation."); + } + if (service !== null && systemdScope === "--system") { + const expectedCommand = `host-daemon --auto-update --host-daemon-port "${port}" --server-url "${systemdEscape(serverUrl)}"`; + const expectedLauncher = `"${systemdEscape(join(dataDir, "npm", "bin", "bb-app"))}"`; + if (!service.includes(expectedCommand) || !service.includes(expectedLauncher)) { + throw new Error( + "Machine system service command belongs to another installation.", + ); + } + } + let serviceRegistered = true; + if (service !== null && systemdScope === "--system") { + const loadedPath = (await deps.run("systemctl", [ + "--system", "show", "--property=FragmentPath", "--value", serviceName, + ])).trim(); + serviceRegistered = loadedPath.length > 0; + if (loadedPath && (await realpath(loadedPath)) !== (await realpath(servicePath))) + throw new Error("Systemd loaded another machine service."); + } + async function connected() { + const raw = await deps.status(port); + if (raw === null) return false; + const status = statusSchema.parse(raw); + if ( + status.hostId !== options.hostId || + normalizedUrl(status.serverUrl) !== normalizedUrl(serverUrl) + ) + throw new Error("Daemon port belongs to another host or server."); + return true; + } + const active = await connected(); + const pidPath = join(dataDir, "install-daemon.pid"); + const pidText = await optionalText(pidPath); + const pid = pidText === null ? null : Number(pidText.trim()); + if (pid !== null && (!Number.isInteger(pid) || pid <= 1)) + throw new Error("Invalid installed daemon PID."); + const launcher = join(dataDir, "npm", "bin", "bb-app"); + async function ownedPid() { + if (pid === null) return false; + let command: string; + try { + command = await deps.run("ps", ["-p", String(pid), "-o", "command="]); + } catch (error) { + if (error instanceof Error && "code" in error && error.code === 1) + return false; + throw error; + } + if (command.trim().length === 0) return false; + const canonicalLauncher = await realpath(launcher); + const words = ` ${command.trim()} `; + if ( + (!words.includes(` ${launcher} `) && + !words.includes(` ${canonicalLauncher} `)) || + !words.includes(" host-daemon ") || + !words.includes(` --host-daemon-port ${port} `) || + !words.includes(` --server-url ${serverUrl} `) + ) + throw new Error("Recorded daemon PID belongs to another process."); + return true; + } + const livePid = await ownedPid(); + const reservation = join( + deps.homeDir, + ".bb-machines", + "host-daemon-ports", + rawPort, + ); + const reservationOwner = ( + await optionalText(join(reservation, "data-dir")) + )?.trim(); + if (operation === "start") { + if (active) return; + if (service !== null) { + if (deps.platform === "darwin") + await deps.run("launchctl", [ + "bootstrap", + `gui/${deps.uid}`, + servicePath, + ]); + else { + if (!serviceRegistered) + await deps.run("systemctl", [systemdScope, "enable", servicePath]); + await deps.run("systemctl", [systemdScope, "start", serviceName]); + } + } else if (!livePid) { + const newPid = await deps.start( + launcher, + [ + "host-daemon", + "--auto-update", + "--host-daemon-port", + rawPort, + "--server-url", + serverUrl, + ], + dataDir, + ); + await writeFile(pidPath, `${newPid}\n`, { mode: 0o600 }); + } + for (let attempt = 0; attempt < 80; attempt++) { + if (await connected()) return; + await deps.sleep(); + } + throw new Error("Machine daemon did not start within 20 seconds."); + } + if (service !== null && serviceRegistered) { + if (deps.platform === "darwin") { + let loaded = true; + try { + await deps.run("launchctl", [ + "print", + `gui/${deps.uid}/${serviceName}`, + ]); + } catch { + loaded = false; + } + if (loaded) + await deps.run("launchctl", [ + "bootout", + `gui/${deps.uid}`, + servicePath, + ]); + } else + await deps.run("systemctl", [ + systemdScope, + operation === "uninstall" ? "disable" : "stop", + ...(operation === "uninstall" ? ["--now"] : []), + serviceName, + ]); + } + if (livePid && pid !== null && (await ownedPid())) deps.kill(pid); + for (let attempt = 0; attempt < 80; attempt++) { + if (!(await connected()) && !(await ownedPid())) break; + if (attempt === 79) + throw new Error("Machine daemon did not stop within 20 seconds."); + await deps.sleep(); + } + await rm(pidPath, { force: true }); + if (operation === "stop") return; + if (service !== null) { + await rm(servicePath); + if (deps.platform === "linux") + await deps.run("systemctl", [systemdScope, "daemon-reload"]); + } + if (reservationOwner === dataDir) { + if ((await realpath(reservation)) !== reservation) + throw new Error("Refusing a symlinked port reservation."); + await rm(join(reservation, "data-dir")); + await rmdir(reservation); + } + await rm(dataDir, { recursive: true }); +} + +export function registerMachineLifecycleCommands(machine: Command): void { + for (const operation of ["start", "stop", "uninstall"] as const) { + machine + .command(operation) + .description( + `${operation === "start" ? "Start" : operation === "stop" ? "Stop" : "Uninstall"} an owned local machine daemon`, + ) + .requiredOption("--host-id ", "Expected enrolled host identity") + .option( + "--server-url ", + "Assert the installation belongs to this server", + ) + .option( + "--data-dir ", + "Select an installer-owned machine directory", + ) + .option("--json", "Print machine-readable JSON output") + .action( + action(async (options: LifecycleOptions & { json?: boolean }) => { + await runMachineLifecycle(operation, { + ...options, + dataDir: options.dataDir ?? process.env.BB_DATA_DIR, + }); + if ( + !outputJson(options, { + hostId: options.hostId, + operation, + status: "complete", + }) + ) { + console.log(`Machine ${options.hostId}: ${operation} complete.`); + } + }), + ); + } +} diff --git a/apps/cli/src/commands/machine.ts b/apps/cli/src/commands/machine.ts index 7cf8e8b438..77812920c5 100644 --- a/apps/cli/src/commands/machine.ts +++ b/apps/cli/src/commands/machine.ts @@ -1,6 +1,12 @@ +import { registerMachineEnvironmentCommands } from "./machine-environment.js"; +import { registerMachineLifecycleCommands } from "./machine-lifecycle.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"; @@ -8,6 +14,14 @@ import { confirmDestructiveAction } from "./helpers.js"; interface MachineListCommandOptions { json?: boolean; + project?: string; +} + +interface MachineCreateCommandOptions extends MachineListCommandOptions { + provider: string; + wait: boolean; + key?: string; + inputs?: string; } interface MachineMutationCommandOptions extends MachineListCommandOptions { @@ -125,6 +139,272 @@ export function registerMachineCommands( .command("machine") .description("Inspect execution machines"); + registerMachineLifecycleCommands(machine); + 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 durable launch ID immediately") + .requiredOption("--provider ", "Machine provider ID") + .option( + "--key ", + "Reuse a stable key when retrying creation", + ) + .option("--inputs ", "Provider inputs as JSON") + .option("--project ", "Project ID or exact project name") + .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()); + let projectId: string | null = null; + if (opts.project !== undefined) { + const target = opts.project.trim(); + if (!target) throw new Error("Project must not be empty."); + const projects = await sdk.projects.list({ + includePersonal: true, + signal: controller.signal, + }); + const byId = projects.find((project) => project.id === target); + const matches = byId + ? [byId] + : projects.filter((project) => project.name === target); + if (matches.length === 0) throw new Error("Project was not found."); + if (matches.length > 1) { + throw new Error("Project name is ambiguous; use its ID."); + } + projectId = matches[0].id; + } + controller.signal.throwIfAborted(); + let launch = await sdk.hosts.submit({ + machineProviderId, + projectId, + inputs, + ...(key === undefined ? {} : { key }), + signal: controller.signal, + }); + let command: string | null = null; + if (machineProviderId === "manual") { + while (launch.phase === "creating" && command === null) { + controller.signal.throwIfAborted(); + command = ( + await sdk.hosts.experimental_enrollmentCommand({ + id: launch.id, + signal: controller.signal, + }) + ).command; + if (command !== null) break; + await new Promise((resolve) => setTimeout(resolve, 100)); + launch = await sdk.hosts.launch({ + id: launch.id, + signal: controller.signal, + }); + } + } + if (!opts.wait) { + if ( + !outputJson( + opts, + machineProviderId === "manual" + ? { ...launch, command } + : launch, + ) + ) + console.log([launch.id, command ?? launch.step].join("\n")); + return; + } + if (command !== null) console.error(command); + console.error(`Following machine launch ${launch.id}`); + let step = ""; + const host = await sdk.hosts.follow({ + id: launch.id, + signal: controller.signal, + onProgress: (status) => { + if (status.step !== step) { + step = status.step; + console.error(step); + } + }, + }); + if (!outputJson(opts, host)) + console.log(`Machine ${host.id} created`); + } catch (error) { + if (controller.signal.aborted) { + throw new CliExitError( + "Stopped following; creation continues. Use bb machine cancel to cancel.", + 130, + ); + } + throw error; + } finally { + process.off("SIGINT", cancel); + } + }), + ); + + machine + .command("cancel ") + .description("Explicitly cancel a durable machine launch") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (id: string, opts: MachineListCommandOptions) => { + const result = await createCliBbSdk(getUrl()).hosts.cancel({ id }); + if (!outputJson(opts, result)) + console.log(`${result.id}: ${result.phase}`); + }), + ); + + machine + .command("status ") + .description("Show durable machine launch progress") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (id: string, opts: MachineListCommandOptions) => { + const result = await createCliBbSdk(getUrl()).hosts.launch({ id }); + if (!outputJson(opts, result)) + console.log( + `${result.id}: ${result.phase} — ${result.message ?? result.step}`, + ); + }), + ); + + machine + .command("lifecycle ") + .description("Show deadline, preservation and retention state") + .option("--keep", "Keep this machine past automatic retention deletion") + .option("--no-keep", "Restore automatic retention deletion") + .option("--remove", "Remove the machine and its retained snapshots") + .option("--yes", "Skip removal confirmation") + .option("--json", "Print machine-readable JSON output") + .action( + action( + async ( + target: string, + opts: { + keep?: boolean; + remove?: boolean; + yes?: boolean; + json?: boolean; + }, + ) => { + const sdk = createCliBbSdk(getUrl()); + const hostId = resolveMachineId(await sdk.hosts.list(), target); + if (opts.remove) { + if (opts.keep !== undefined) + throw new Error("Cannot combine --remove and --keep/--no-keep"); + if ( + !opts.yes && + !(await confirmDestructiveAction( + `Remove machine ${hostId} and its snapshots?`, + )) + ) + return; + const removed = await sdk.hosts.delete({ hostId }); + if (!outputJson(opts, removed)) + console.log(`Machine ${hostId} removed`); + return; + } + const result = await sdk.hosts.experimental_lifecycle({ + hostId, + keep: opts.keep, + }); + if (!outputJson(opts, result)) + console.log( + `${result.phase}: ${result.recoveryState}${result.message === null ? "" : ` — ${result.message}`}\nMaintenance: ${result.maintenanceAt === null ? "none" : new Date(result.maintenanceAt).toISOString()}\nExpiry: ${result.expiresAt === null ? "none" : new Date(result.expiresAt).toISOString()}\nAutomatic deletion: ${result.keep ? "disabled (kept)" : result.retentionAt === null ? "not scheduled" : new Date(result.retentionAt).toISOString()}\nControls: --keep, --no-keep, --remove --yes`, + ); + }, + ), + ); + + machine + .command("ready ") + .description("Check CLI, authentication and project workspace readiness") + .requiredOption("--provider ", "Agent provider") + .requiredOption("--project ", "Project ID") + .option("--json", "Print machine-readable JSON output") + .action( + action( + async ( + target: string, + opts: { provider: string; project: string; json?: boolean }, + ) => { + const sdk = createCliBbSdk(getUrl()); + const hostId = resolveMachineId(await sdk.hosts.list(), target); + const result = await sdk.hosts.experimental_ensureReady({ + hostId, + projectId: opts.project, + providerId: opts.provider, + }); + if (!outputJson(opts, result)) + console.log( + result.status === "ready" + ? "Machine is ready" + : `${result.stage}: ${result.message}`, + ); + if (result.status === "blocked") + throw new CliExitError("Machine readiness is blocked", 1); + }, + ), + ); + + machine + .command("providers") + .description("List installed machine providers") + .option("--project ", "Evaluate availability for a project") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (opts: MachineListCommandOptions) => { + const providers = await createCliBbSdk(getUrl()).hosts.listProviders({ + ...(opts.project === undefined ? {} : { projectId: opts.project }), + }); + 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} ${provider.availability?.status ?? "available"}`, + ) + .join("\n"), + ); + }), + ); + machine .command("list") .description("List execution machines") @@ -149,7 +429,12 @@ export function registerMachineCommands( action(async (target: string, opts: MachineListCommandOptions) => { const sdk = createCliBbSdk(getUrl()); const hostId = resolveMachineId(await sdk.hosts.list(), target); - const host = await sdk.hosts.get({ hostId }); + const host = { + ...(await sdk.hosts.get({ hostId })), + providerDetails: await sdk.hosts.experimental_providerDetails({ + hostId, + }), + }; if (outputJson(opts, host)) return; console.log(JSON.stringify(host, null, 2)); }), @@ -195,7 +480,8 @@ export function registerMachineCommands( .action( action(async (target: string, opts: MachineMutationCommandOptions) => { const sdk = createCliBbSdk(getUrl()); - const hostId = resolveMachineId(await sdk.hosts.list(), target); + const hosts = await sdk.hosts.list(); + const hostId = resolveMachineId(hosts, target); if ( !opts.yes && !(await confirmDestructiveAction(`Remove machine ${hostId}?`)) @@ -204,6 +490,13 @@ export function registerMachineCommands( const result = await sdk.hosts.delete({ hostId }); if (outputJson(opts, result)) return; console.log(`Machine ${hostId} removed`); + if ( + hosts.find((host) => host.id === hostId)?.machineProviderId === + "manual" + ) + console.log( + `Uninstall manually on the machine: bb machine uninstall --host-id ${hostId}`, + ); }), ); @@ -221,6 +514,48 @@ export function registerMachineCommands( }), ); + machine + .command("suspend ") + .description("Suspend a provider-managed execution machine") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (target: string, opts: MachineListCommandOptions) => { + const sdk = createCliBbSdk(getUrl()); + const hostId = resolveMachineId(await sdk.hosts.list(), target); + const result = await sdk.hosts.suspend({ hostId }); + if (outputJson(opts, result)) return; + console.log(`Machine ${hostId} suspended`); + }), + ); + + machine + .command("resume ") + .description("Resume a suspended provider-managed execution machine") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (target: string, opts: MachineListCommandOptions) => { + const sdk = createCliBbSdk(getUrl()); + const hostId = resolveMachineId(await sdk.hosts.list(), target); + const result = await sdk.hosts.resume({ hostId }); + if (outputJson(opts, result)) return; + console.log(`Machine ${hostId} resumed`); + }), + ); + + machine + .command("retry-cleanup ") + .description("Retry a failed provider teardown immediately") + .option("--json", "Print machine-readable JSON output") + .action( + action(async (target: string, opts: MachineListCommandOptions) => { + const sdk = createCliBbSdk(getUrl()); + const hostId = resolveMachineId(await sdk.hosts.list(), target); + const result = await sdk.hosts.retryCleanup({ hostId }); + if (outputJson(opts, result)) return; + console.log(`Machine ${hostId} cleanup retried`); + }), + ); + const providerCli = machine .command("provider-cli") .description("Inspect and install provider CLIs on a machine"); @@ -272,19 +607,21 @@ function printMachineTable(hosts: Host[]): void { host.name, host.id, host.status, + host.machineProviderId ?? "user-enrolled", formatMachineLastSeen(host.lastSeenAt, now), ]); const widths = [ Math.max(4, ...rows.map((row) => row[0].length)), Math.max(2, ...rows.map((row) => row[1].length)), Math.max(6, ...rows.map((row) => row[2].length)), - Math.max(9, ...rows.map((row) => row[3].length)), + Math.max(8, ...rows.map((row) => row[3].length)), + Math.max(9, ...rows.map((row) => row[4].length)), ]; console.log(""); console.log( renderBorderlessTable( { - head: ["Name", "ID", "Status", "Last seen"], + head: ["Name", "ID", "Status", "Provider", "Last seen"], colWidths: widths, trimTrailingWhitespace: true, }, diff --git a/apps/cli/src/commands/thread/spawn.ts b/apps/cli/src/commands/thread/spawn.ts index f4b12a8990..29831327b3 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,7 @@ async function buildProviderSpawnEnvironment(args: { newEnvironmentKind: string | undefined; baseBranch: string | undefined; machineHostId: string | null; + machine: EnvironmentMachineSelection | null; projectId: string; resolveDefaultHostId: () => Promise; }): Promise { @@ -235,7 +250,7 @@ async function buildProviderSpawnEnvironment(args: { `The '${match.id}' environment provider takes no --environment-inputs.`, ); } - const machine = { + const machine = args.machine ?? { type: "existing" as const, hostId: requireHostId( args.machineHostId ?? (await args.resolveDefaultHostId()), @@ -278,6 +293,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; 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 +358,26 @@ 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) { + throw new Error( + "--machine-inputs requires --new-machine .", + ); + } if ( machineTarget && environmentValue && @@ -350,9 +387,57 @@ export function registerSpawnCommand( "Cannot combine --machine or --host with an existing environment ID; that environment already selects its machine.", ); } - const selectedEnvironmentProvider = opts.environmentProvider; + const machineProvider = opts.newMachine + ? ( + await createCliBbSdk(getUrl()).hosts.listProviders({ projectId }) + ).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 ?? + machineProvider?.environmentRow?.environmentProviderId; + if (machineProvider && selectedEnvironmentProvider === undefined) { + throw new Error( + `The '${machineProvider.id}' machine provider has no environment row; combine --new-machine with --environment-provider .`, + ); + } const needsHostId = !opts.environmentProvider && + !opts.newMachine && (Boolean(opts.newEnvironment) || (environmentValue !== undefined && looksLikePath(environmentValue))); @@ -373,6 +458,7 @@ export function registerSpawnCommand( newEnvironmentKind: opts.newEnvironment, baseBranch: opts.baseBranch, machineHostId: hostId, + machine: newMachineSelection, projectId, resolveDefaultHostId: resolveLocalHostId, }) diff --git a/apps/cli/src/plugin-cli-proxy.ts b/apps/cli/src/plugin-cli-proxy.ts index 848093114f..92c3bc0a6b 100644 --- a/apps/cli/src/plugin-cli-proxy.ts +++ b/apps/cli/src/plugin-cli-proxy.ts @@ -290,6 +290,24 @@ async function materializeStdinFlag( argv: readonly string[], input: PluginCliInputStream, ): Promise { + if (argv.includes("--stdin")) { + if (input.isTTY === true) throw new Error("--stdin requires piped input."); + if (argv.includes("--input-text")) + throw new Error("Choose --stdin or --input-text."); + const chunks: Buffer[] = []; + let bytes = 0; + for await (const chunk of input) { + const data = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + bytes += data.length; + if (bytes > 256 * 1024) throw new Error("--stdin exceeds 256 KiB."); + chunks.push(data); + } + return argv.flatMap((value) => + value === "--stdin" + ? ["--input-text", Buffer.concat(chunks).toString("utf8")] + : [value], + ); + } const matches = argv.flatMap((flag, index) => { const match = PLUGIN_CLI_STDIN_FLAG.exec(flag); const name = match?.[1]; @@ -373,42 +391,65 @@ export async function runPluginCliCommand( ); return 1; } - const threadId = resolveContextThreadId(); - const projectId = resolveContextProjectId(); - const response = await cliFetch( - `${baseUrl}/api/v1/plugins/${encodeURIComponent(pluginId)}/cli`, - { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - argv: resolvedArgv, - cwd: process.cwd(), - ...(threadId ? { threadId } : {}), - ...(projectId ? { projectId } : {}), - }), - dispatcher: getPluginCliDispatcher(), - }, - ); - const result = (await response.json().catch(() => null)) as { - exitCode?: unknown; - stdout?: unknown; - stderr?: unknown; - error?: unknown; - } | null; - if (result === null || typeof result.exitCode !== "number") { - await writePluginCliOutput( - streams.stderr, - typeof result?.error === "string" - ? result.error - : `Unexpected response from the plugin CLI endpoint (HTTP ${response.status})`, + for (;;) { + const threadId = resolveContextThreadId(); + const projectId = resolveContextProjectId(); + const response = await cliFetch( + `${baseUrl}/api/v1/plugins/${encodeURIComponent(pluginId)}/cli`, + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + argv: resolvedArgv, + cwd: process.cwd(), + ...(threadId ? { threadId } : {}), + ...(projectId ? { projectId } : {}), + }), + dispatcher: getPluginCliDispatcher(), + }, ); - return 1; - } - if (typeof result.stdout === "string" && result.stdout.length > 0) { - await writePluginCliOutput(streams.stdout, result.stdout); - } - if (typeof result.stderr === "string" && result.stderr.length > 0) { - await writePluginCliOutput(streams.stderr, result.stderr); + const result = (await response.json().catch(() => null)) as { + experimental_continue?: unknown; + exitCode?: unknown; + stdout?: unknown; + stderr?: unknown; + error?: unknown; + } | null; + if (result === null || typeof result.exitCode !== "number") { + await writePluginCliOutput( + streams.stderr, + typeof result?.error === "string" + ? result.error + : `Unexpected response from the plugin CLI endpoint (HTTP ${response.status})`, + ); + return 1; + } + if (typeof result.stdout === "string" && result.stdout.length > 0) { + await writePluginCliOutput(streams.stdout, result.stdout); + } + if (typeof result.stderr === "string" && result.stderr.length > 0) { + await writePluginCliOutput(streams.stderr, result.stderr); + } + if (result.exitCode !== 0 || result.experimental_continue === undefined) + return result.exitCode; + const continuation = result.experimental_continue; + if ( + typeof continuation !== "object" || + continuation === null || + !("argv" in continuation) || + !Array.isArray(continuation.argv) || + !continuation.argv.every( + (value): value is string => typeof value === "string", + ) || + !("delayMs" in continuation) || + typeof continuation.delayMs !== "number" || + !Number.isFinite(continuation.delayMs) || + continuation.delayMs < 0 || + continuation.delayMs > 60000 + ) + throw new Error("Invalid plugin CLI continuation"); + resolvedArgv = continuation.argv; + const delayMs = continuation.delayMs; + await new Promise((resolve) => setTimeout(resolve, delayMs)); } - return result.exitCode; } diff --git a/apps/demo-server/src/demo-world.ts b/apps/demo-server/src/demo-world.ts index 0d2c5484e0..969364d09e 100644 --- a/apps/demo-server/src/demo-world.ts +++ b/apps/demo-server/src/demo-world.ts @@ -50,11 +50,28 @@ import { const SYSTEM_CONFIG = systemConfigResponseSchema.parse({ ...configFixture, + machineGit: { + status: "not configured", + statusMessage: "gh is not logged in on the server", + }, generalSettings: defaultAppSettings, experiments: { ...defaultExperiments, mobileApp: true }, appearance: defaultAppTheme, featureFlags: defaultFeatureFlags, serverUrl: "https://demo.invalid", + serverAccess: { + effectiveUrl: "https://demo.invalid", + urlSource: "setting", + defaultProviderId: "direct", + providers: [ + { + id: "direct", + displayName: "Direct URL", + attention: null, + availability: { status: "available" }, + }, + ], + }, aiServices: { inference: "codex/gpt-5.5", inferenceFallback: "codex/gpt-5.5", diff --git a/apps/demo-server/src/fixtures/world.ts b/apps/demo-server/src/fixtures/world.ts index 5af853b318..0a115a3794 100644 --- a/apps/demo-server/src/fixtures/world.ts +++ b/apps/demo-server/src/fixtures/world.ts @@ -165,7 +165,15 @@ export function hosts(now: number): Host[] { id: DEMO_HOST_ID, name: "demo", status: "connected", - type: "persistent", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + 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..a513687fd3 100644 --- a/apps/host-daemon/src/app.test.ts +++ b/apps/host-daemon/src/app.test.ts @@ -376,7 +376,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", @@ -438,7 +437,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 +538,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 +767,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..89832cbce6 100644 --- a/apps/host-daemon/src/app.ts +++ b/apps/host-daemon/src/app.ts @@ -47,11 +47,7 @@ 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, @@ -106,15 +102,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 +282,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 +461,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) => { @@ -767,7 +761,7 @@ export async function createHostDaemonApp( providerHealth: async (args) => { await refreshRuntimeShellEnv(); return runtimeManager.withProviderMaintenanceRuntime( - { dataDir: options.dataDir }, + { dataDir: options.dataDir, contributedEnv: args.contributedEnv }, (runtime) => runtime.providerHealth(args), ); }, @@ -814,13 +808,11 @@ export async function createHostDaemonApp( 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, 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-support.ts b/apps/host-daemon/src/command-dispatch-support.ts index f511f4156b..f45bb50c98 100644 --- a/apps/host-daemon/src/command-dispatch-support.ts +++ b/apps/host-daemon/src/command-dispatch-support.ts @@ -5,6 +5,7 @@ import type { EventSinkInput } from "./event-sink.js"; import type { EnvironmentHookProgressMessage, HostDaemonCommand, + HostDaemonContributedEnvEntry, ProviderHealthResult, ProviderUsageResult, HostDaemonBridgeLaunch, @@ -71,6 +72,7 @@ export interface CommandDispatchOptions { providerHealth: (args: { providerId: string; bridgeLaunch: AgentRuntimeBridgeLaunch; + contributedEnv?: HostDaemonContributedEnvEntry[]; cwd?: string; }) => Promise; providerUsage: (args: { 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 107148ac4e..088b51bcf3 100644 --- a/apps/host-daemon/src/command-dispatch.ts +++ b/apps/host-daemon/src/command-dispatch.ts @@ -1,7 +1,19 @@ +import { jsonValueSchema } from "@bb/domain"; +import { providerHealthResultSchema } from "@bb/provider-bridge-protocol"; +import { + operationEnvironment, + operationSecrets, + redactOperationContent, + daemonPrivateEnvironmentValues, +} from "./operation-environment.js"; import { runEnvironmentHook, cancelEnvironmentHook, } from "./command-handlers/environment-hook.js"; +import { + inspectReadiness, + probeReadiness, +} from "./command-handlers/readiness.js"; import { providerCliInstallEventSchema, type HostDaemonCommand, @@ -466,6 +478,19 @@ const commandHandlers: CommandHandlerMap = { cloneProject({ dataDir: options.dataDir, projectSlug: command.projectSlug, + env: operationEnvironment( + command.contributedEnv, + { + ...process.env, + ...options.runtimeManager.getShellEnv(), + }, + true, + ), + redactValues: daemonPrivateEnvironmentValues({ + ...process.env, + ...options.runtimeManager.getShellEnv(), + }), + contributedEnv: command.contributedEnv, remoteUrl: command.remoteUrl, ...userExecutableProcessOptions(options.runtimeManager.getShellEnv()), ...(command.targetPath !== undefined @@ -636,11 +661,20 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { command.bridgeLaunch, options, ); - return options.providerHealth({ + const result = await options.providerHealth({ providerId: command.providerId, + ...(command.contributedEnv !== undefined + ? { contributedEnv: command.contributedEnv } + : {}), ...(command.cwd !== undefined ? { cwd: command.cwd } : {}), bridgeLaunch, }); + return providerHealthResultSchema.parse( + redactOperationContent( + jsonValueSchema.parse(result), + operationSecrets(command.contributedEnv ?? []), + ), + ); }, "provider.usage": async (command, options) => { const bridgeLaunch = await resolveRuntimeBridgeLaunch( @@ -653,6 +687,9 @@ const onlineRpcHandlers: OnlineRpcHandlerMap = { bridgeLaunch, }); }, + "workspace.readiness.inspect": (command) => inspectReadiness(command.path), + "host.readiness.probe": (command, options) => + probeReadiness(command, options.runtimeManager.getShellEnv().BB_SERVER_URL), "provider.installation.status": async (command, options) => { const bridgeLaunch = await resolveRuntimeBridgeLaunch( command.bridgeLaunch, diff --git a/apps/host-daemon/src/command-handlers/environment-hook.ts b/apps/host-daemon/src/command-handlers/environment-hook.ts index 8289a7b172..4401252f70 100644 --- a/apps/host-daemon/src/command-handlers/environment-hook.ts +++ b/apps/host-daemon/src/command-handlers/environment-hook.ts @@ -1,3 +1,7 @@ +import { + operationSecrets, + redactOperationSecrets, +} from "../operation-environment.js"; import type { CommandDispatchOptions, CommandOf, @@ -33,6 +37,7 @@ export async function runEnvironmentHook( command: CommandOf<"environment.hook.run">, options: CommandDispatchOptions, ): Promise> { + const secrets = operationSecrets(command.contributedEnv); const active = controllers(options); const existing = active.get(command.operationId); if (existing === "cancelled") @@ -48,22 +53,33 @@ 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: redactOperationSecrets(entry.text, secrets), + status: entry.status ?? null, + }, + }), + }); + } catch (error) { + throw new Error( + redactOperationSecrets( + error instanceof Error ? error.message : String(error), + secrets, + ), + ); + } return {}; }, ); diff --git a/apps/host-daemon/src/command-handlers/project.ts b/apps/host-daemon/src/command-handlers/project.ts index ddefdb0ba4..e1885e9535 100644 --- a/apps/host-daemon/src/command-handlers/project.ts +++ b/apps/host-daemon/src/command-handlers/project.ts @@ -1,3 +1,8 @@ +import { + operationSecrets, + redactOperationSecrets, +} from "../operation-environment.js"; +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; import fs from "node:fs/promises"; import path from "node:path"; import { @@ -71,6 +76,9 @@ export async function cloneProject(args: { dataDir: string; projectSlug: string; remoteUrl: string; + env?: NodeJS.ProcessEnv; + redactValues?: readonly string[]; + contributedEnv?: readonly HostDaemonContributedEnvEntry[]; targetPath?: string; shellPath?: string; }): Promise<{ path: string; gitRemoteUrl: string | null }> { @@ -83,14 +91,29 @@ export async function cloneProject(args: { try { await runGit(["clone", args.remoteUrl, targetPath], { cwd: path.dirname(targetPath), + env: args.env, ...(args.shellPath !== undefined ? { shellPath: args.shellPath } : {}), timeoutMs: PROJECT_CLONE_TIMEOUT_MS, }); } catch (error) { if (error instanceof WorkspaceError) { - throw new ExpectedCommandDispatchError(error.code, error.message); + throw new ExpectedCommandDispatchError( + error.code, + redactOperationSecrets(error.message, [ + ...operationSecrets(args.contributedEnv ?? []), + ...(args.redactValues ?? []), + ]), + ); } - throw error; + throw new Error( + redactOperationSecrets( + error instanceof Error ? error.message : String(error), + [ + ...operationSecrets(args.contributedEnv ?? []), + ...(args.redactValues ?? []), + ], + ), + ); } return inspectProjectPath( targetPath, diff --git a/apps/host-daemon/src/command-handlers/readiness.test.ts b/apps/host-daemon/src/command-handlers/readiness.test.ts new file mode 100644 index 0000000000..4a0e5822f8 --- /dev/null +++ b/apps/host-daemon/src/command-handlers/readiness.test.ts @@ -0,0 +1,104 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, it } from "vitest"; +import { inspectReadiness, probeReadiness } from "./readiness.js"; + +const exec = promisify(execFile); + +it("fingerprints tracked checkout inputs without executing the repository hook", async () => { + const path = await mkdtemp(join(tmpdir(), "bb-readiness-")); + const git = (...args: string[]) => exec("git", ["-C", path, ...args]); + try { + await git("init"); + await writeFile(join(path, "package-lock.json"), "first"); + await writeFile(join(path, ".bb-env-setup.sh"), "exit 99\n"); + await git("add", "."); + await git( + "-c", + "user.name=Fixture", + "-c", + "user.email=fixture@example.com", + "commit", + "-m", + "Fixture", + ); + const first = await inspectReadiness(path); + if ("kind" in first) throw new Error("Expected checkout"); + expect(first.dirty).toEqual([]); + expect(first.files.map((file) => file.path)).toEqual([ + ".bb-env-setup.sh", + "package-lock.json", + ]); + expect(first.abi).toBe( + `${process.platform}/${process.arch}/node-${process.versions.modules}`, + ); + await writeFile(join(path, "untracked-lock.json"), "ignored"); + await writeFile(join(path, "package-lock.json"), "second"); + const changed = await inspectReadiness(path); + if ("kind" in changed) throw new Error("Expected checkout"); + expect(changed.commit).toBe(first.commit); + expect(changed.files).toHaveLength(2); + expect(changed.files[1]?.sha256).not.toBe(first.files[1]?.sha256); + expect(changed.dirty).toEqual([" M package-lock.json"]); + await rm(join(path, "package-lock.json")); + await symlink(".bb-env-setup.sh", join(path, "package-lock.json")); + await expect(inspectReadiness(path)).rejects.toThrow( + "Unsupported readiness input file", + ); + } finally { + await rm(path, { recursive: true, force: true }); + } +}); + +it("probes the authenticated server route without following redirects or sending headers to another origin", async () => { + let calls = 0; + const server = createServer((request, response) => { + calls++; + if (request.url === "/redirect") { + response.writeHead(302, { location: "/ready" }).end(); + return; + } + response + .writeHead(request.headers.authorization === "fixture-token" ? 200 : 401) + .end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + if (!address || typeof address === "string") + throw new Error("Missing TCP listener"); + const base = `http://127.0.0.1:${address.port}`; + const input = { + type: "host.readiness.probe" as const, + serverPath: "/ready", + headers: { authorization: "fixture-token" }, + }; + try { + expect(await probeReadiness(input, base)).toEqual({ + reachable: true, + status: 200, + }); + expect(await probeReadiness({ ...input, headers: {} }, base)).toEqual({ + reachable: false, + status: 401, + }); + expect( + await probeReadiness({ ...input, serverPath: "/redirect" }, base), + ).toEqual({ reachable: false, status: null }); + expect( + await probeReadiness( + { ...input, serverPath: "//example.com/ready" }, + base, + ), + ).toEqual({ reachable: false, status: null }); + expect(calls).toBe(3); + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}); diff --git a/apps/host-daemon/src/command-handlers/readiness.ts b/apps/host-daemon/src/command-handlers/readiness.ts new file mode 100644 index 0000000000..b917e16c6a --- /dev/null +++ b/apps/host-daemon/src/command-handlers/readiness.ts @@ -0,0 +1,103 @@ +import { execFile } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import type { + HostDaemonOnlineRpcCommand, + HostDaemonOnlineRpcResult, +} from "@bb/host-daemon-contract"; + +const exec = promisify(execFile); +export async function inspectReadiness( + path: string, +): Promise> { + const git = async (...args: string[]) => + ( + await exec("git", ["-C", path, ...args], { + maxBuffer: 8 * 1024 * 1024, + timeout: 30000, + }) + ).stdout; + const directory = await realpath(path); + const inside = await git("rev-parse", "--is-inside-work-tree").catch( + (error: unknown) => { + if ( + error instanceof Error && + error.message.includes("not a git repository") + ) + return "false"; + throw error; + }, + ); + if (inside.trim() !== "true") { + const hook = join(directory, ".bb-env-setup.sh"); + const stat = await lstat(hook).catch((error: unknown) => { + if (error instanceof Error && "code" in error && error.code === "ENOENT") + return null; + throw error; + }); + if (stat && (!stat.isFile() || stat.size > 16 * 1024 * 1024)) + throw new Error("Unsupported readiness input file"); + return { + kind: "directory" as const, + path: directory, + hookSha256: stat + ? createHash("sha256") + .update(await readFile(hook)) + .digest("hex") + : null, + }; + } + const [tracked, dirty, commit] = await Promise.all([ + git("ls-files", "-z"), + git("status", "--porcelain=v1", "-z", "--untracked-files=no"), + git("rev-parse", "HEAD"), + ]); + const files = []; + for (const name of tracked.split("\0").filter(Boolean)) { + if ( + !/(^|\/)([^/]*lock[^/]*|go\.sum|package\.json|\.bb-env-setup\.sh|\.worktreeinclude|\.node-version|\.nvmrc)$/.test( + name, + ) + ) + continue; + const file = join(path, name); + const stat = await lstat(file).catch(() => null); + if (!stat) continue; + if (!stat.isFile() || stat.size > 16 * 1024 * 1024) + throw new Error("Unsupported readiness input file"); + files.push({ + path: name, + sha256: createHash("sha256") + .update(await readFile(file)) + .digest("hex"), + }); + } + return { + commit: commit.trim(), + dirty: dirty.split("\0").filter(Boolean), + files, + abi: `${process.platform}/${process.arch}/node-${process.versions.modules}`, + }; +} +export async function probeReadiness( + input: Extract, + serverUrl: string | undefined, +) { + if (!serverUrl) return { reachable: false, status: null }; + try { + const base = new URL(serverUrl); + const url = new URL(input.serverPath, base); + if (url.origin !== base.origin) return { reachable: false, status: null }; + const response = await fetch(url, { + headers: input.headers, + redirect: "error", + signal: AbortSignal.timeout(15000), + }); + await response.body?.cancel(); + return { reachable: response.ok, status: response.status }; + } catch { + return { reachable: false, status: null }; + } +} 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..7ea03e7d9e 100644 --- a/apps/host-daemon/src/environment-lifecycle-script.ts +++ b/apps/host-daemon/src/environment-lifecycle-script.ts @@ -1,8 +1,14 @@ +import { StringDecoder } from "node:string_decoder"; +import { + operationEnvironment, + operationSecrets, + createSecretStreamRedactor, +} 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 +31,8 @@ export interface RunSetupScriptArgs { workspacePath: string; timeoutMs: number; shellPath?: string; + env?: NodeJS.ProcessEnv; + contributedEnv?: readonly HostDaemonContributedEnvEntry[]; onProgress?: ProgressCallback; signal?: AbortSignal; } @@ -121,11 +129,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 +158,20 @@ 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 redactor = createSecretStreamRedactor( + operationSecrets(args.contributedEnv ?? []), + ); + const emit = (text: string) => { + outputChunks.push(text); + emitScriptOutputLines(outputLineReader.push(text)); + }; + stream.on("data", (chunk: Buffer) => + emit(redactor.push(decoder.write(chunk))), + ); + return () => emit(redactor.push(decoder.end()) + redactor.flush()); + }); const timeout = setTimeout(() => { timedOut = true; @@ -185,6 +203,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 6b6aa307ed..40b6df26a4 100644 --- a/apps/host-daemon/src/index.ts +++ b/apps/host-daemon/src/index.ts @@ -55,13 +55,11 @@ async function runHostDaemonEntrypoint(): Promise { 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..4c0fe1d1b5 --- /dev/null +++ b/apps/host-daemon/src/operation-environment.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { + createSecretStreamRedactor, + operationEnvironment, + redactOperationSecrets, + redactOperationContent, +} 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", + secret: true, + }, + { + name: "PROXY", + value: { serverPath: "/proxy" }, + source: { core: "machine-git" }, + reason: "Proxy", + secret: false, + }, + ], + base, + ), + ).toEqual({ + ...base, + GH_TOKEN: "secret", + PROXY: "https://server.example/proxy", + }); + expect(base).not.toHaveProperty("GH_TOKEN"); + }); + + it("redacts terminal secrets split at every possible chunk boundary", () => { + const secret = "ghp_private-token"; + for (let split = 0; split <= secret.length; split += 1) { + const redactor = createSecretStreamRedactor([secret]); + const output = + redactor.push(`before ${secret.slice(0, split)}`) + + redactor.push(`${secret.slice(split)} after`) + + redactor.flush(); + expect(output).toBe("before [redacted] after"); + } + }); +}); + +it("redacts complete clone diagnostics with newline conversion and overlapping secrets", () => { + expect( + redactOperationSecrets("first\r\nsecond redacted", [ + "first\nsecond", + "redacted", + ]), + ).toBe("[redacted] [redacted]"); +}); + +it("fails closed when content cannot be traversed", () => { + const value = Object.defineProperty({}, "text", { + enumerable: true, + get() { + throw new Error("secret"); + }, + }); + expect(redactOperationContent(value, ["secret"])).toBe("[redacted]"); +}); diff --git a/apps/host-daemon/src/operation-environment.ts b/apps/host-daemon/src/operation-environment.ts new file mode 100644 index 0000000000..7ee1451193 --- /dev/null +++ b/apps/host-daemon/src/operation-environment.ts @@ -0,0 +1,87 @@ +import { + createSecretStreamRedactor, + sanitizeInheritedChildProcessEnv, +} from "@bb/process-utils"; +import type { JsonValue } from "@bb/domain"; +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; +} + +export function operationSecrets( + entries: readonly HostDaemonContributedEnvEntry[], +): string[] { + return entries.flatMap((entry) => + entry.secret && typeof entry.value === "string" && entry.value.length > 0 + ? [entry.value] + : [], + ); +} + +export function redactOperationSecrets( + text: string, + secrets: readonly string[], +): string { + try { + const redactor = createSecretStreamRedactor(secrets); + return redactor.push(text) + redactor.flush(); + } catch { + return "[redacted]"; + } +} + +export function redactOperationContent( + value: JsonValue, + secrets: readonly string[], +): JsonValue { + function visit(content: JsonValue): JsonValue { + if (typeof content === "string") + return redactOperationSecrets(content, secrets); + if (Array.isArray(content)) return content.map(visit); + if (content !== null && typeof content === "object") + return Object.fromEntries( + Object.entries(content).map(([key, entry]) => [key, visit(entry)]), + ); + return content; + } + try { + return visit(value); + } catch { + return "[redacted]"; + } +} + +export { createSecretStreamRedactor }; + +export function daemonPrivateEnvironmentValues( + env: NodeJS.ProcessEnv, +): string[] { + const values = Object.entries(env).flatMap(([key, value]) => + key.startsWith("BB_") && value ? [value] : [], + ); + if (env.BB_SERVER_HEADERS) { + try { + const headers: unknown = JSON.parse(env.BB_SERVER_HEADERS); + if (headers && typeof headers === "object") + for (const value of Object.values(headers)) + if (typeof value === "string" && value) values.push(value); + } catch {} + } + return values; +} diff --git a/apps/host-daemon/src/plugin-host-manager.test.ts b/apps/host-daemon/src/plugin-host-manager.test.ts index 311d037e9a..18404da108 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,152 @@ describe("PluginHostManager", () => { expect(fetchArtifact).toHaveBeenCalledOnce(); }); + it("scopes setup env, waits for rotation, and redacts secrets returned by a worker", async () => { + const manager = await createManager({ + shellEnv: () => ({ npm_config_user_agent: "test" }), + }); + const contribution = (value: string) => [ + { + name: "GATE_VALUE", + value, + secret: false, + reason: "Gate", + source: { core: "machine-environment" as const }, + }, + { + name: "GH_TOKEN", + value: 'worker-secret\nwith"quotes', + secret: true, + 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: "[redacted]", + }); + expect(rotated.output).toEqual({ + before: "rotated", + after: "rotated", + token: "[redacted]", + }); + 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, + secret: true, + reason: "Probe", + source: { core: "machine-environment" }, + }, + ], + }); + const payload = { + type: "[redacted]", + true: true, + ok: true, + nested: [{ text: "[redacted]" }], + }; + 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("[redacted]"); + }, + ); + + it.each([ + "first-line\nsecond-line", + "first-line\r\nsecond-line", + "π-first\nsecond-line", + ])( + "redacts worker stderr before framing, across byte chunks and rotation: %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, + secret: true, + 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: "[redacted]" }, + "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([ + "[redacted]", + "[redacted]", + ]); + }, + ); + 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..a87161be6c 100644 --- a/apps/host-daemon/src/plugin-host-manager.ts +++ b/apps/host-daemon/src/plugin-host-manager.ts @@ -1,3 +1,9 @@ +import { + operationEnvironment, + operationSecrets, + redactOperationSecrets, + redactOperationContent, +} from "./operation-environment.js"; import { fork, type ChildProcess } from "node:child_process"; import { existsSync } from "node:fs"; import { rm } from "node:fs/promises"; @@ -5,6 +11,7 @@ import { isAbsolute } from "node:path"; import { performance } from "node:perf_hooks"; import { fileURLToPath } from "node:url"; import type { Readable } from "node:stream"; +import { StringDecoder } from "node:string_decoder"; import type { HostDaemonOnlineRpcCommand, HostDaemonOnlineRpcResult, @@ -13,6 +20,7 @@ import type { HostPathWatchChange, HostWatcher } from "@bb/host-watcher"; import { jsonValueSchema, type JsonValue } from "@bb/domain"; import { createPluginProcessTempDir, + createSecretStreamRedactor, ensurePluginProcessDataDir, sanitizeInheritedChildProcessEnv, } from "@bb/process-utils"; @@ -60,6 +68,7 @@ interface WorkerState { retainedLeaseIds: Set; idleTimer: NodeJS.Timeout | null; watches: Map; + secrets: Set; } interface WorkerWatchState { @@ -169,7 +178,10 @@ function errorMessage(error: unknown): string { function observeBoundedStderr( source: Readable, onLine: (line: string) => void, + getSecrets: () => readonly string[], ): void { + const decoder = new StringDecoder("utf8"); + const redactor = createSecretStreamRedactor(getSecrets); let tail = Buffer.alloc(0); let emittedLines = 0; let truncated = false; @@ -198,7 +210,7 @@ function observeBoundedStderr( onLine(tail.toString("utf8").replace(/\r$/u, "")); tail = Buffer.alloc(0); }; - source.on("data", (chunk: Buffer) => { + const consume = (chunk: Buffer): void => { if (truncated) return; let remaining = chunk; while (remaining.length > 0 && !truncated) { @@ -211,8 +223,14 @@ function observeBoundedStderr( emit(); remaining = remaining.subarray(newline + 1); } + }; + source.on("data", (chunk: Buffer) => { + consume(Buffer.from(redactor.push(decoder.write(chunk)))); + }); + source.once("end", () => { + consume(Buffer.from(redactor.push(decoder.end()) + redactor.flush())); + emit(); }); - source.on("end", emit); } function sendToWorker(child: ChildProcess, message: object): boolean { @@ -270,6 +288,8 @@ export class PluginHostManager { `host plugin ${command.pluginId} has too many pending calls`, ); } + for (const secret of operationSecrets(command.contributedEnv)) + worker.secrets.add(secret); return await new Promise((resolve, reject) => { const deadlineTimer = setTimeout( () => @@ -296,6 +316,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); @@ -506,6 +530,7 @@ export class PluginHostManager { retainedLeaseIds: new Set(), idleTimer: null, watches: new Map(), + secrets: new Set(), }; let unexpectedExitReported = false; const failWorker = ( @@ -541,12 +566,20 @@ export class PluginHostManager { }, START_TIMEOUT_MS); startTimer.unref?.(); if (child.stderr !== null) { - observeBoundedStderr(child.stderr, (line) => { - this.options.logger.warn( - { pluginId: worker.pluginId, origin: "host", stderr: line }, - "Host plugin stderr", - ); - }); + observeBoundedStderr( + child.stderr, + (line) => { + this.options.logger.warn( + { + pluginId: worker.pluginId, + origin: "host", + stderr: line, + }, + "Host plugin stderr", + ); + }, + () => [...worker.secrets], + ); } child.once("error", (error) => { clearTimeout(startTimer); @@ -583,7 +616,7 @@ export class PluginHostManager { } if (record.type === "startup-error" && typeof record.error === "string") { clearTimeout(startTimer); - failWorker(record.error); + failWorker(redactOperationSecrets(record.error, [...worker.secrets])); return; } if ( @@ -601,7 +634,7 @@ export class PluginHostManager { pluginId: worker.pluginId, generation: worker.generation, signal: record.signal, - payload: payload.data, + payload: redactOperationContent(payload.data, [...worker.secrets]), }); return; } @@ -925,13 +958,16 @@ export class PluginHostManager { pending.reject(pending.cancellationError); } else if (result.ok) { const output = jsonValueSchema.safeParse(result.output); - if (output.success) pending.resolve({ output: output.data }); + if (output.success) + pending.resolve({ + output: redactOperationContent(output.data, [...worker.secrets]), + }); else pending.reject(new Error("host handler returned invalid JSON")); } else { pending.reject( new Error( typeof result.error === "string" - ? result.error + ? redactOperationSecrets(result.error, [...worker.secrets]) : "host handler failed", ), ); 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/runtime-manager.test.ts b/apps/host-daemon/src/runtime-manager.test.ts index 0448960ba3..8bb29b0a64 100644 --- a/apps/host-daemon/src/runtime-manager.test.ts +++ b/apps/host-daemon/src/runtime-manager.test.ts @@ -1085,6 +1085,51 @@ describe("RuntimeManager", () => { ); }); + it("isolates contributed authentication environments from shared maintenance and other checks", async () => { + const dataDir = await makeTempDir("bb-auth-readiness-"); + const runtimes = [ + createFakeRuntime(), + createFakeRuntime(), + createFakeRuntime(), + ]; + const createRuntime = vi + .fn() + .mockReturnValueOnce(runtimes[0]) + .mockReturnValueOnce(runtimes[1]) + .mockReturnValueOnce(runtimes[2]); + const manager = new RuntimeManager({ + createRuntime, + shellEnv: { PATH: "/bin", OPENAI_API_KEY: "shell-key" }, + }); + await manager.ensureProviderMaintenanceRuntime({ dataDir }); + for (const value of ["first", "rotated"]) { + await manager.withProviderMaintenanceRuntime( + { + dataDir, + contributedEnv: [ + { + name: "OPENAI_API_KEY", + value, + secret: true, + reason: "Fixture", + source: { core: "machine-environment" }, + }, + ], + }, + async () => undefined, + ); + } + expect(createRuntime.mock.calls[0]?.[0].env).not.toHaveProperty( + "OPENAI_API_KEY", + ); + expect(createRuntime.mock.calls[1]?.[0].env.OPENAI_API_KEY).toBe("first"); + expect(createRuntime.mock.calls[2]?.[0].env.OPENAI_API_KEY).toBe("rotated"); + expect(runtimes[0]?.shutdown).not.toHaveBeenCalled(); + expect(runtimes[1]?.shutdown).toHaveBeenCalledTimes(1); + expect(runtimes[2]?.shutdown).toHaveBeenCalledTimes(1); + await manager.shutdownAll(); + }); + it("recreates the provider maintenance runtime after base shell env changes", async () => { const dataDir = await makeTempDir("bb-provider-maintenance-"); const firstRuntime = createFakeRuntime(); diff --git a/apps/host-daemon/src/runtime-manager.ts b/apps/host-daemon/src/runtime-manager.ts index 3800d10035..23e134ded7 100644 --- a/apps/host-daemon/src/runtime-manager.ts +++ b/apps/host-daemon/src/runtime-manager.ts @@ -1,3 +1,4 @@ +import { operationEnvironment } from "./operation-environment.js"; import { mkdir } from "node:fs/promises"; import path from "node:path"; import { @@ -17,6 +18,7 @@ import type { import { threadScope, turnScope } from "@bb/domain"; import type { HostDaemonActiveThread, + HostDaemonContributedEnvEntry, HostDaemonEnvironmentChange, HostDaemonLoadedEnvironment, HostDaemonInjectedSkillSource, @@ -860,9 +862,22 @@ export class RuntimeManager { } async withProviderMaintenanceRuntime( - args: { dataDir: string }, + args: { + dataDir: string; + contributedEnv?: readonly HostDaemonContributedEnvEntry[]; + }, request: (runtime: AgentRuntime) => Promise, ): Promise { + if (args.contributedEnv !== undefined) { + const runtime = await this.createProviderMaintenanceRuntime(args); + try { + return await request(runtime); + } catch { + throw new Error("Provider authentication check failed"); + } finally { + await runtime.shutdown(); + } + } this.clearProviderMaintenanceIdleTimer(); this.providerMaintenanceActiveRequests += 1; try { @@ -1152,6 +1167,7 @@ export class RuntimeManager { private async createProviderMaintenanceRuntime(args: { dataDir: string; + contributedEnv?: readonly HostDaemonContributedEnvEntry[]; }): Promise { const workspacePath = path.join( args.dataDir, @@ -1161,7 +1177,16 @@ export class RuntimeManager { let runtime: AgentRuntime | null = null; const shellEnv = this.getShellEnv(); - const providerProcessEnv = providerProcessEnvFromShellEnv(shellEnv); + const providerProcessEnv = + args.contributedEnv === undefined + ? providerProcessEnvFromShellEnv(shellEnv) + : Object.fromEntries( + Object.entries( + operationEnvironment(args.contributedEnv, shellEnv), + ).filter( + (entry): entry is [string, string] => entry[1] !== undefined, + ), + ); runtime = this.createRuntime({ workspacePath, additionalWorkspaceWriteRoots: [], @@ -1182,7 +1207,8 @@ export class RuntimeManager { success: true, })), onInteractiveRequest: this.options.onInteractiveRequest, - onStderr: this.options.onStderr, + onStderr: + args.contributedEnv === undefined ? this.options.onStderr : undefined, onProcessExit: (info) => { if ( runtime && @@ -1191,7 +1217,8 @@ export class RuntimeManager { ) { this.providerMaintenanceRuntime = null; } - this.options.onProcessExit?.(info); + if (args.contributedEnv === undefined) + this.options.onProcessExit?.(info); }, }); return runtime; 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..3680440505 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"; @@ -54,14 +53,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; diff --git a/apps/host-daemon/src/server-connection.test.ts b/apps/host-daemon/src/server-connection.test.ts index 8146f93e16..87f9d26f1c 100644 --- a/apps/host-daemon/src/server-connection.test.ts +++ b/apps/host-daemon/src/server-connection.test.ts @@ -23,8 +23,7 @@ interface CreateWebSocketFixtureArgs { interface ConnectionFixtureArgs extends CreateServerClientFixtureArgs { autoReconnect?: boolean; - connectMachineId?: string; - machineCredential?: string; + serverHeaders?: Record; protocolSelfUpdater?: ProtocolSelfUpdater; onSelfUpdateInstalled?: () => void | Promise; startupTimeoutMs?: number; @@ -170,15 +169,11 @@ 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", @@ -280,7 +275,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 +287,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 +298,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..1aae3e0593 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(), 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/terminals/terminal-manager.test.ts b/apps/host-daemon/src/terminals/terminal-manager.test.ts index 4c504308f4..ec95b6e8b7 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,31 @@ describe("TerminalManager", () => { ).resolves.toEqual([]); }); + it("injects host credentials into a PTY and redacts terminal output", async () => { + const harness = createHarness(); + await openTerminal(harness, [ + { + name: "GH_TOKEN", + value: "terminal-private-token", + source: { core: "machine-git" }, + reason: "Git", + secret: true, + }, + ]); + expect(harness.adapter.spawned[0]?.args.env.GH_TOKEN).toBe( + "terminal-private-token", + ); + expect(JSON.stringify(harness.messages)).not.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 +488,7 @@ describe("TerminalManager", () => { await harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-host-path", terminalId: "term-host-path", target: { @@ -501,6 +524,7 @@ describe("TerminalManager", () => { await harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-host-home", terminalId: "term-host-home", target: { @@ -540,6 +564,7 @@ describe("TerminalManager", () => { const openPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -602,6 +627,7 @@ describe("TerminalManager", () => { const openPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -660,6 +686,7 @@ describe("TerminalManager", () => { const openPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -709,6 +736,7 @@ describe("TerminalManager", () => { const firstOpenPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -727,6 +755,7 @@ describe("TerminalManager", () => { const secondOpenPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-2", terminalId: "term-1", threadId: "thr-1", @@ -781,6 +810,7 @@ describe("TerminalManager", () => { const firstOpenPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -799,6 +829,7 @@ describe("TerminalManager", () => { const secondOpenPromise = harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-2", terminalId: "term-1", threadId: "thr-1", @@ -859,6 +890,7 @@ describe("TerminalManager", () => { await harness.manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-stale", terminalId: "term-stale", threadId: "thr-1", @@ -1406,6 +1438,7 @@ describe("TerminalManager", () => { await manager.handleMessage({ type: "terminal.open", + contributedEnv: [], requestId: "open-1", terminalId: "term-1", threadId: "thr-1", @@ -1464,6 +1497,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..0df65242f9 100644 --- a/apps/host-daemon/src/terminals/terminal-manager.ts +++ b/apps/host-daemon/src/terminals/terminal-manager.ts @@ -1,3 +1,8 @@ +import { + operationEnvironment, + operationSecrets, + createSecretStreamRedactor, +} from "../operation-environment.js"; import { accessSync, chmodSync, constants, existsSync } from "node:fs"; import { access, stat } from "node:fs/promises"; import { createRequire } from "node:module"; @@ -581,14 +586,20 @@ export class TerminalManager { try { const target = await this.resolveTerminalOpenTarget(message); const shell = await this.resolveShell(); + const redactor = createSecretStreamRedactor( + operationSecrets(message.contributedEnv), + ); const pty = this.ptyAdapter.spawn({ 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, @@ -618,8 +629,11 @@ export class TerminalManager { ); } session.disposables.push( - pty.onData((data) => this.handleTerminalOutput(session, data)), + pty.onData((data) => + this.handleTerminalOutput(session, redactor.push(data)), + ), pty.onExit((event) => { + this.handleTerminalOutput(session, redactor.flush()); void this.runTerminalOperation({ operation: () => this.finishTerminalSession({ diff --git a/apps/host-daemon/test/command/environment-hook.test.ts b/apps/host-daemon/test/command/environment-hook.test.ts index 315fd1f1b2..8f22fab7fd 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,127 @@ 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 without leaking secrets into progress", + 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, + secret: true, + source: { core: "machine-environment" as const }, + reason: "test", + }, + { + name: "HOOK_PLAIN", + value: "configured", + secret: false, + 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")).not.toContain(secret); + expect(output.join("\n")).toContain("[redacted]"); + expect(process.env.GH_TOKEN).not.toBe(secret); + }, +); + +it.each(["setup", "teardown"] as const)( + "redacts multiline secrets before streaming %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", + secret: true, + source: { core: "machine-environment" as const }, + reason: "test", + }, + ], + resumeOnly: false, + operationId: `redact-${kind}`, + path, + kind, + timeoutMs: 5000, + }, + options, + ); + expect(output.join("\n")).not.toContain("PRIVATE_BODY"); + expect(output.join("\n")).toContain("[redacted]"); + }, +); + +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, + secret: false, + 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..faf3ddaf3f --- /dev/null +++ b/apps/host-daemon/test/command/project-clone-private-env.test.ts @@ -0,0 +1,60 @@ +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 from real Git helpers and redacts clone failures", 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, + secret: name === "CONTRIBUTED_SECRET", + source: { core: "machine-environment" as const }, + reason: "test", + })); + let failure = ""; + try { + await dispatchCommand( + { + type: "project.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("[redacted]"); + expect(failure).not.toContain("private-daemon-token"); + expect(failure).not.toContain("contributed-private"); +}); diff --git a/apps/server/src/assets/install-machine.sh b/apps/server/src/assets/install-machine.sh index 6867d4cb60..807f5d1592 100755 --- a/apps/server/src/assets/install-machine.sh +++ b/apps/server/src/assets/install-machine.sh @@ -5,6 +5,7 @@ set -eu usage() { cat >&2 <<'EOF' Usage: install.sh --join-code --host-id --server [--machine-code ] [--host-daemon-port ] + install.sh --bootstrap-env 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,6 +13,7 @@ EOF exit 2 } +bootstrap_env= join_code= host_id= server_url= @@ -98,10 +100,11 @@ ready_row() { while [ "$#" -gt 0 ]; do case "$1" in - --join-code|--host-id|--server|--machine-code|--host-daemon-port) + --bootstrap-env|--join-code|--host-id|--server|--machine-code|--host-daemon-port) [ "$#" -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 ;; @@ -118,10 +121,32 @@ while [ "$#" -gt 0 ]; do esac done -[ -n "$join_code" ] || usage +if [ -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 (![1, 2].includes(bundle.version) || 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 - printf '\n %s\n\n' "$(bold "bb machine setup")" active_step "Setting up this machine as $host_id for $server_url" @@ -153,6 +178,11 @@ 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 + require_npm() { if ! command -v npm >/dev/null 2>&1; then fail_step "bb-app installation requires npm." @@ -167,11 +197,45 @@ 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" canonical_data_dir=$(node -e ' @@ -375,6 +439,58 @@ 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" +if [ -n "$bootstrap_env" ]; then + bootstrap_payload=$(BB_ENROLLMENT="$bootstrap_payload" node --input-type=module - "$data_dir/enrollment-bootstrap.json" <<'NODE' +import { readFile, writeFile, rename } from "node:fs/promises"; +import { dirname, join } from "node:path"; +const path = process.argv[2]; +let bundle = JSON.parse(process.env.BB_ENROLLMENT); +if (bundle.version === 1) { + if (bundle.expiresAt <= Date.now()) throw new Error("Machine enrollment bootstrap has expired"); + let previous; + try { previous = JSON.parse(await readFile(path, "utf8")); } catch {} + if (!previous) { + try { + const config = JSON.parse(await readFile(join(dirname(path), "config.json"), "utf8")); + const hostId = (await readFile(join(dirname(path), "host-id"), "utf8")).trim(); + if (hostId === bundle.hostId && new URL(config.serverUrl).href === new URL(bundle.serverUrl).href && config.serverHeaders?.["x-bb-connect-machine"]) { + previous = { ...bundle, version: 2, headers: config.serverHeaders }; + } + } catch {} + } + let headers; + if (bundle.client?.kind === "connect") { + if (previous?.version === 2 && previous.hostId === bundle.hostId && previous.serverUrl === bundle.serverUrl && previous.credential === bundle.credential) { + headers = previous.headers; + } else { + if (bundle.client.expiresAt <= Date.now()) throw new Error("Machine access code has expired"); + const base = new URL(bundle.serverUrl); + base.hostname = base.hostname.split(".").slice(1).join("."); + const response = await fetch(new URL("/api/connect/redeem-machine", base), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify({ code: bundle.client.machineCode }), signal: AbortSignal.timeout(60000), + }); + if (!response.ok) throw new Error(`Machine redeem failed (${response.status})`); + const result = await response.json(); + if (typeof result.credential !== "string" || !result.credential || new URL(result.serverUrl).href !== new URL(bundle.serverUrl).href) throw new Error("Invalid machine access response"); + headers = { "x-bb-connect-machine": result.credential }; + } + } else if (bundle.client?.kind !== "direct") throw new Error("Invalid machine enrollment bootstrap"); + const { client, ...fields } = bundle; + bundle = { ...fields, version: 2, ...(headers ? { headers } : {}) }; +} +await writeFile(`${path}.tmp`, JSON.stringify(bundle), { mode: 0o600 }); +await rename(`${path}.tmp`, path); +process.stdout.write(JSON.stringify(bundle)); +NODE + ) || exit 1 +fi +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 +513,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 +522,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 +624,18 @@ 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= + rm -f "$data_dir/enrollment-bootstrap.json" +fi + if [ -n "$machine_code" ]; then connect_apex=$(node -e ' const url = new URL(process.argv[1]); @@ -638,8 +766,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 +836,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 +896,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 +915,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 +968,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/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..a419ba5bdf 100644 --- a/apps/server/src/internal/session.ts +++ b/apps/server/src/internal/session.ts @@ -69,7 +69,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) { @@ -110,15 +109,11 @@ export function registerInternalSessionRoutes( 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 +122,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 41fdb69007..7a161a99ef 100644 --- a/apps/server/src/routes/environments.ts +++ b/apps/server/src/routes/environments.ts @@ -1,3 +1,4 @@ +import { getMachineLifecycle } from "../services/machines/lifecycle.js"; import { parseOptionalInteger } from "../services/lib/validation.js"; import path from "node:path"; import { @@ -140,6 +141,12 @@ async function getPullRequestForWorkspaceTarget( deps: AppDeps, target: ReturnType, ): Promise { + const lifecycle = getMachineLifecycle(deps, target.hostId); + if ( + lifecycle !== undefined && + (lifecycle.observedState !== "running" || lifecycle.leaseId !== null) + ) + return null; const result = await callHostRetryableOnlineRpc(deps, { hostId: target.hostId, timeoutMs: COMMAND_TIMEOUT_MS, diff --git a/apps/server/src/routes/hosts.ts b/apps/server/src/routes/hosts.ts index 26f9868322..aa428efe37 100644 --- a/apps/server/src/routes/hosts.ts +++ b/apps/server/src/routes/hosts.ts @@ -1,3 +1,12 @@ +import { getMachineEnrollmentService } from "../services/machines/machine-services.js"; +import { manualEnrollmentCommand } from "../services/machines/manual-enrollment-command.js"; +import { + machineLifecycleStatus, + observeMachineLifecycle, +} from "../services/machines/lifecycle.js"; +import { ensureHostReady } from "../services/machines/readiness.js"; +import { ensureProjectSourceOnHost } from "../services/projects/project-source-setup.js"; +import { serverAccess } from "../services/machines/server-access.js"; import { getNonDestroyedHost, updateHost } from "@bb/db"; import { publicApiRoutes, @@ -7,7 +16,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 +37,24 @@ 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, callHostRetryableOnlineRpc, } from "../services/hosts/online-rpc.js"; import { handleHostRemoved } from "../internal/session-owner-side-effects.js"; +import { + submitMachine, + resolveThreadMachineLaunchKey, + machineLaunchStatus, + cancelMachineLaunch, + getMachineProviderDetails, + requestMachineResume, + requestMachineRemoval, + requestMachineSuspension, + retryMachineCleanup, + sweepProviderMachine, +} from "../services/machines/provider-orchestration.js"; const PROVIDER_CLI_INSTALL_TIMEOUT_MS = 15 * 60 * 1000; const FOLDER_PICKER_TIMEOUT_MS = 10 * 60 * 1000; @@ -97,9 +121,43 @@ export function registerHostRoutes( }); const routes = publicApiRoutes.hosts; - post(routes.createJoinCode, async (context) => { + post(routes.create, async (context, payload) => { + assertHostManagementAllowed(context); + const host = await submitMachine(deps, payload); + return context.json(host, 201); + }); + + get(routes.launch, (context) => { assertHostManagementAllowed(context); - const issued = await issuePersistentHostEnrollKey(deps, { + return context.json(machineLaunchStatus(deps, context.req.param("id"))); + }); + + get(routes.experimental_enrollmentCommand, async (context, query) => { + assertHostManagementAllowed(context); + context.header("Cache-Control", "no-store"); + const id = context.req.param("id"); + const launchId = + query.scope === "thread" ? resolveThreadMachineLaunchKey(deps, id) : id; + const bootstrap = + await getMachineEnrollmentService(deps).pendingBootstrapForLaunch( + launchId, + ); + return context.json({ + command: bootstrap === null ? null : manualEnrollmentCommand(bootstrap), + }); + }); + + post(routes.cancelLaunch, async (context) => { + assertHostManagementAllowed(context); + const key = context.req.param("id"); + machineLaunchStatus(deps, key); + await cancelMachineLaunch(deps, key, false, true); + return context.json(machineLaunchStatus(deps, key)); + }); + + post(routes.createJoinCode, async (context, payload) => { + assertHostManagementAllowed(context); + const issued = await issueHostEnrollKey(deps, { enrollSource: "public-multi-machine", }); return context.json( @@ -115,9 +173,11 @@ export function registerHostRoutes( get(routes.list, (context) => context.json(listPublicHostsWithStatus(deps))); get(routes.get, (context) => - context.json( - requireNonDestroyedHostWithStatus(deps, context.req.param("id")), - ), + context.json({ + ...requireNonDestroyedHostWithStatus(deps, context.req.param("id")), + connectMachineId: requireMutableHost(deps, context.req.param("id")) + .connectMachineId, + }), ); patch(routes.update, (context, payload) => { @@ -170,6 +230,34 @@ export function registerHostRoutes( return context.json({ ok: true as const }); }); + get(routes.experimental_providerDetails, async (context) => { + return context.json( + await getMachineProviderDetails( + deps, + context.req.param("id"), + context.req.raw.signal, + ), + ); + }); + + post(routes.suspend, async (context) => { + assertHostManagementAllowed(context); + await requestMachineSuspension(deps, context.req.param("id")); + return context.json({ ok: true as const }); + }); + + post(routes.resume, async (context) => { + assertHostManagementAllowed(context); + await requestMachineResume(deps, context.req.param("id")); + return context.json({ ok: true as const }); + }); + + 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 +270,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) { @@ -264,6 +364,44 @@ export function registerHostRoutes( return context.json(result); }); + post(routes.experimental_lifecycle, async (context, payload) => { + const hostId = context.req.param("id"); + assertUsableHostId(deps, { hostId }); + await observeMachineLifecycle(deps, hostId).catch(() => {}); + return context.json(machineLifecycleStatus(deps, hostId, payload)); + }); + + post(routes.experimental_ensureReady, async (context, payload) => { + const hostId = context.req.param("id"); + assertUsableHostId(deps, { hostId }); + const project = requirePublicStandardProject(deps.db, payload.projectId); + try { + const source = await ensureProjectSourceOnHost(deps, { + projectId: project.id, + projectName: project.name, + hostId, + remoteUrl: project.gitRemoteUrl, + }); + return context.json( + await ensureHostReady(deps, { + ...payload, + hostId, + threadId: null, + path: source.path, + }), + ); + } catch { + return context.json({ + status: "blocked" as const, + code: "checkout_failed", + stage: "workspace" as const, + message: + "Project checkout could not be prepared; check repository access", + retryable: true, + }); + } + }); + get(routes.providerCliStatus, async (context) => { const hostId = context.req.param("id"); assertUsableHostId(deps, { hostId }); @@ -294,16 +432,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, () => + callHostOnlineRpc(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..e11666d659 100644 --- a/apps/server/src/routes/plugins.ts +++ b/apps/server/src/routes/plugins.ts @@ -839,7 +839,11 @@ export function registerPluginRoutes( if (!outcome.ok) { return context.json( { ok: false, error: outcome.error }, - outcome.error.code === "invalid_input" ? 400 : 500, + outcome.error.code === "conflict" + ? 409 + : outcome.error.code === "invalid_input" + ? 400 + : 500, ); } return context.json({ ok: true, result: outcome.result }); diff --git a/apps/server/src/routes/projects.ts b/apps/server/src/routes/projects.ts index 99b29262f6..d568b2b6e5 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 { @@ -51,7 +49,11 @@ import { PROMPT_HISTORY_ENTRY_LIMIT } from "@bb/domain"; import { resolveCreateThreadExecutionDefaults } from "../services/threads/thread-default-policy.js"; 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, @@ -96,7 +98,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( @@ -299,19 +300,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 }, @@ -478,63 +466,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 81ea61d1db..e00203ca26 100644 --- a/apps/server/src/routes/system.ts +++ b/apps/server/src/routes/system.ts @@ -1,3 +1,10 @@ +import { + effectiveMachineGitHealth, + machineEnvironmentView, + updateMachineEnvironment, +} from "../services/machines/environment-settings.js"; +import { getGateAuthKind } from "../request-context.js"; +import { serverAccessStatus } from "../services/machines/server-access.js"; import { getAppSettings, getAppKeybindingOverrides, @@ -31,6 +38,10 @@ import { getEnvironmentProvider, 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"; @@ -62,6 +73,11 @@ import { environmentProviderAcceptsEmptyInputs, resolveEnvironmentProviderAvailability, } from "../services/environments/provider-availability.js"; +import { + machineProviderAcceptsEmptyInputs, + resolveMachineProviderAvailability, + resolveMachineProviderEnvironmentRow, +} from "../services/machines/provider-availability.js"; import { requirePublicProject } from "../services/lib/entity-lookup.js"; const LEADING_ENVIRONMENT_PROVIDER_IDS: readonly string[] = [ @@ -117,7 +133,7 @@ export function registerSystemRoutes( deps: ServerAppDeps, pluginService: PluginService, ): void { - const { get, post, put } = typedRoutes(app, { + const { get, post, put, del } = typedRoutes(app, { onValidationError: (msg) => new ApiError(400, "invalid_request", msg), }); const routes = publicApiRoutes.system; @@ -170,6 +186,8 @@ export function registerSystemRoutes( ]; return { generalSettings: compatibleGeneralSettings(), + serverAccess: await serverAccessStatus(deps), + machineGit: await effectiveMachineGitHealth(deps.db), keybindings: applyAppKeybindingOverrides( DEFAULT_APP_KEYBINDINGS, keybindingOverrides, @@ -220,6 +238,41 @@ export function registerSystemRoutes( showUnhandledProviderEvents: settings.showDiagnosticEvents, }; } + get(routes.machineEnvironment, async (context) => + context.json(await machineEnvironmentView(deps.db)), + ); + put(routes.setMachineEnvironment, async (context, payload) => { + if (getGateAuthKind(context) === "machine") + throw new ApiError( + 403, + "forbidden", + "Machine credentials cannot change global environment settings", + ); + await updateMachineEnvironment( + deps.db, + deps.config.dataDir, + payload.name, + payload, + ); + deps.hub.notifySystem(["config-changed"]); + return context.json(await machineEnvironmentView(deps.db)); + }); + del(routes.unsetMachineEnvironment, async (context) => { + if (getGateAuthKind(context) === "machine") + throw new ApiError( + 403, + "forbidden", + "Machine credentials cannot change global environment settings", + ); + await updateMachineEnvironment( + deps.db, + deps.config.dataDir, + context.req.param("name"), + null, + ); + deps.hub.notifySystem(["config-changed"]); + return context.json(await machineEnvironmentView(deps.db)); + }); put(routes.generalSettings, (context, payload) => { const { showUnhandledProviderEvents, ...settings } = payload; @@ -394,6 +447,38 @@ export function registerSystemRoutes( }); }); + get(routes.machineProviders, async (context, query) => { + return context.json({ + providers: await Promise.all( + listMachineProviders().map(async (record) => ({ + id: record.provider.id, + displayName: record.provider.displayName, + 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, + requires: record.provider.requires, + inputs: record.provider.inputsJsonSchema, + acceptsEmptyInputs: await machineProviderAcceptsEmptyInputs(record), + supportsSuspend: record.provider.suspend !== null, + environmentRow: resolveMachineProviderEnvironmentRow( + deps, + record, + query, + ), + policy: record.provider.policy, + availability: await resolveMachineProviderAvailability( + deps, + record, + query, + ), + })), + ), + }); + }); + get(routes.providers, async (context, query) => context.json(await listSystemProviderInfos(deps, query)), ); @@ -402,7 +487,9 @@ export function registerSystemRoutes( const providerId = context.req.param("id"); const registration = providerId.startsWith("environment:") ? getEnvironmentProvider(providerId.slice("environment:".length)) - : deps.providerRegistry.get(providerId); + : providerId.startsWith("machine:") + ? getMachineProvider(providerId.slice("machine:".length)) + : deps.providerRegistry.get(providerId); if (registration?.icon !== undefined) { return pluginImageResponse( context, diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 9c964be9b3..119c2f5e16 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,4 +1,5 @@ import { recheckEnvironmentLaunch } from "./services/threads/thread-environment-providers.js"; +import { getMachineEnrollmentService } from "./services/machines/machine-services.js"; import { registerDesktopBrowserRoutes } from "./routes/desktop-browsers.js"; import { createNodeWebSocket } from "@hono/node-ws"; import { createHash } from "node:crypto"; @@ -36,6 +37,8 @@ import { setEnvironmentLaunchRecheckHandler, setPluginEnvironmentProviderBridge, } from "./services/plugins/plugin-environment-provider-registry.js"; +import { setServerAccessBridge } from "./services/plugins/plugin-server-access-registry.js"; +import { setPluginMachineProviderBridge } from "./services/plugins/plugin-machine-provider-registry.js"; import { recheckEnvironmentProviderLaunches } from "./services/threads/thread-environment-providers.js"; import { invalidateEnvironmentProviderAvailability } from "./services/environments/provider-availability.js"; import { requestQueuedMessageDispatch } from "./services/threads/queued-message-dispatch.js"; @@ -557,6 +560,7 @@ export function createApp( return next(); }); const pluginService = createPluginService({ + machineEnrollments: getMachineEnrollmentService(deps), db: deps.db, hub: deps.hub, logger: deps.logger, @@ -616,6 +620,8 @@ export function createApp( setEnvironmentLaunchRecheckHandler((threadId) => recheckEnvironmentLaunch(deps, threadId), ); + setPluginMachineProviderBridge(pluginService.machineProviders); + setServerAccessBridge(pluginService.serverAccessProviders); setEnvironmentProviderRecheckHandler((pluginId) => { invalidateEnvironmentProviderAvailability(); deps.hub.notifySystem(["config-changed"]); diff --git a/apps/server/src/services/environments/environment-hooks.ts b/apps/server/src/services/environments/environment-hooks.ts index 8dfbacc65a..93df60be09 100644 --- a/apps/server/src/services/environments/environment-hooks.ts +++ b/apps/server/src/services/environments/environment-hooks.ts @@ -1,7 +1,17 @@ +import { resolveHostEnvironment } from "../hosts/host-environment.js"; +import { environmentHookOperations, machineLifecycles } 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, + callHostOnlineRpcWithoutAdmission, +} from "../hosts/online-rpc.js"; +import { + beginEnvironmentSetupOutcome, + finishEnvironmentSetupOutcome, +} from "./setup-outcomes.js"; const HOOK_TIMEOUT_MS = 15 * 60 * 1000; const TRANSPORT_GRACE_MS = 6_000; @@ -47,10 +57,70 @@ export async function runEnvironmentHook( 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); + if (args.kind === "setup") + await finishEnvironmentSetupOutcome(deps, { + hostId: args.hostId, + path: args.path, + operationId: existing.operationId, + succeeded: true, + }); + return; + } const operationId = args.id; + const missingFilesystem = + args.kind === "teardown" && + deps.db + .select({ state: machineLifecycles.observedState }) + .from(machineLifecycles) + .where(eq(machineLifecycles.hostId, args.hostId)) + .get()?.state === "missing"; + if (missingFilesystem) { + const error = + "Teardown could not run: the machine provider confirmed that its filesystem no longer exists."; + deps.db + .insert(environmentHookOperations) + .values({ + id: args.id, + operationId, + hostId: args.hostId, + path: args.path, + kind: args.kind, + startedAt: Date.now(), + finishedAt: Date.now(), + error, + }) + .onConflictDoUpdate({ + target: environmentHookOperations.id, + set: { finishedAt: Date.now(), error }, + }) + .run(); + args.report.log(error); + deps.logger.warn({ hostId: args.hostId, path: args.path }, error); + return; + } + 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(); active.set(operationId, { hostId: args.hostId, report: args.report }); const abort = (): void => { - void callHostOnlineRpc(deps, { + void callHostOnlineRpcWithoutAdmission(deps, { hostId: args.hostId, timeoutMs: TRANSPORT_GRACE_MS, command: { type: "environment.hook.cancel", operationId }, @@ -62,26 +132,50 @@ export async function runEnvironmentHook( ); }; args.signal.addEventListener("abort", abort, { once: true }); + const identity = { hostId: args.hostId, path: args.path, operationId }; try { + if (args.kind === "setup" && existing === undefined) + await beginEnvironmentSetupOutcome(deps, identity); + args.signal.throwIfAborted(); await callHostOnlineRpc(deps, { hostId: args.hostId, timeoutMs: 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: HOOK_TIMEOUT_MS, }, }); + deps.db + .update(environmentHookOperations) + .set({ finishedAt: Date.now() }) + .where(eq(environmentHookOperations.id, args.id)) + .run(); args.signal.throwIfAborted(); + if (args.kind === "setup") + await finishEnvironmentSetupOutcome(deps, { + ...identity, + succeeded: true, + }); } catch (error) { await cancelPendingEnvironmentHook(deps, { id: args.id, hostId: args.hostId, }); - if (args.kind === "setup") throw error; + if (args.kind === "setup") { + await finishEnvironmentSetupOutcome(deps, { + ...identity, + succeeded: false, + }); + throw error; + } const text = error instanceof Error ? error.message : String(error); args.report.log(text); deps.logger.warn( @@ -98,13 +192,31 @@ export async function cancelPendingEnvironmentHook( deps: WorkSessionDeps, args: { id: string; hostId: string }, ): Promise { - const result = await callHostOnlineRpc(deps, { + 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 callHostOnlineRpcWithoutAdmission(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 dc2a03fe2f..3954a70463 100644 --- a/apps/server/src/services/environments/provider-availability.ts +++ b/apps/server/src/services/environments/provider-availability.ts @@ -1,4 +1,4 @@ -import { getProjectSourceByHost } from "@bb/db"; +import { getProjectSourceByHost, projectSourceOwnsPath } from "@bb/db"; import { isLocalPathProjectSource, PERSONAL_PROJECT_ID } from "@bb/domain"; import { z } from "zod"; import type { SystemEnvironmentProvider } from "@bb/server-contract"; @@ -85,9 +85,7 @@ export function resolveEnvironmentProviderAvailability( ): Promise { if (query.hostId !== undefined) return resolveAvailability(deps, record, query); - const hosts = listPublicHostsWithStatus(deps).filter( - (host) => host.type === "persistent", - ); + const hosts = listPublicHostsWithStatus(deps); return Promise.all( hosts.map((host) => resolveAvailability(deps, record, { ...query, hostId: host.id }), @@ -226,7 +224,7 @@ async function resolveAvailability( query.hostId === undefined ? null : getNonDestroyedHostWithStatus(deps, query.hostId); - if (host === null || host.type !== "persistent") return null; + if (host === null) return null; const requires = record.provider.requires; if (requires.projectless !== (project.id === PERSONAL_PROJECT_ID)) return null; @@ -234,7 +232,15 @@ async function resolveAvailability( host === null ? null : getProjectSourceByHost(deps.db, project.id, host.id); const projectCheckout = source !== null && isLocalPathProjectSource(source) - ? { path: source.path } + ? { + path: source.path, + experimental_ownsPath: projectSourceOwnsPath( + deps.db, + project.id, + host.id, + source.path, + ), + } : null; if (requires.projectCheckout && projectCheckout === null) return null; if (requires.gitCheckout) { diff --git a/apps/server/src/services/environments/provider-orchestration.ts b/apps/server/src/services/environments/provider-orchestration.ts index a59c60ed34..a9a1325128 100644 --- a/apps/server/src/services/environments/provider-orchestration.ts +++ b/apps/server/src/services/environments/provider-orchestration.ts @@ -60,7 +60,7 @@ 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; @@ -1079,7 +1079,7 @@ export function persistPendingProviderRequest( message: null, transientFailures: 0, pathKey: threadId, - hostId: intent.machine.hostId, + hostId: intent.machine.type === "existing" ? intent.machine.hostId : null, path: null, claimPath: null, ownsPath: false, diff --git a/apps/server/src/services/environments/setup-outcomes.ts b/apps/server/src/services/environments/setup-outcomes.ts new file mode 100644 index 0000000000..7cdd18937d --- /dev/null +++ b/apps/server/src/services/environments/setup-outcomes.ts @@ -0,0 +1,165 @@ +import { createHash } from "node:crypto"; +import { and, eq, desc } from "drizzle-orm"; +import { environmentSetupOutcomes, environmentHookOperations } from "@bb/db"; +import type { HostDaemonOnlineRpcResult } from "@bb/host-daemon-contract"; +import type { WorkSessionDeps } from "../../types.js"; +import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; + +type SetupIdentity = { hostId: string; path: string; operationId: string }; + +export function environmentSetupInputHash( + facts: HostDaemonOnlineRpcResult<"workspace.readiness.inspect">, +): string { + return createHash("sha256") + .update( + JSON.stringify( + "kind" in facts + ? facts + : { + commit: facts.commit, + files: facts.files, + abi: facts.abi, + }, + ), + ) + .digest("hex"); +} + +async function inspect(deps: WorkSessionDeps, args: SetupIdentity) { + try { + return await callHostRetryableOnlineRpc(deps, { + hostId: args.hostId, + timeoutMs: 60_000, + command: { type: "workspace.readiness.inspect", path: args.path }, + }); + } catch { + return null; + } +} + +export async function beginEnvironmentSetupOutcome( + deps: WorkSessionDeps, + args: SetupIdentity, +): Promise { + const value = { + ...args, + state: "running" as const, + inputHash: null, + updatedAt: Date.now(), + }; + deps.db + .insert(environmentSetupOutcomes) + .values(value) + .onConflictDoUpdate({ + target: [environmentSetupOutcomes.hostId, environmentSetupOutcomes.path], + set: value, + }) + .run(); + const facts = await inspect(deps, args); + deps.db + .update(environmentSetupOutcomes) + .set({ + inputHash: facts === null ? null : environmentSetupInputHash(facts), + }) + .where( + and( + eq(environmentSetupOutcomes.hostId, args.hostId), + eq(environmentSetupOutcomes.path, args.path), + eq(environmentSetupOutcomes.operationId, args.operationId), + ), + ) + .run(); +} + +export async function finishEnvironmentSetupOutcome( + deps: WorkSessionDeps, + args: SetupIdentity & { succeeded: boolean }, +): Promise { + if (args.succeeded) await reconcileLegacyEnvironmentSetupOutcome(deps, args); + const key = and( + eq(environmentSetupOutcomes.hostId, args.hostId), + eq(environmentSetupOutcomes.path, args.path), + eq(environmentSetupOutcomes.operationId, args.operationId), + eq(environmentSetupOutcomes.state, "running"), + ); + if ( + deps.db.select().from(environmentSetupOutcomes).where(key).get() === + undefined + ) + return; + const facts = args.succeeded ? await inspect(deps, args) : null; + const inputHash = facts === null ? null : environmentSetupInputHash(facts); + deps.db.transaction((tx) => { + const row = tx.select().from(environmentSetupOutcomes).where(key).get(); + if (!row) return; + tx.update(environmentSetupOutcomes) + .set({ + state: + args.succeeded && inputHash !== null && inputHash === row.inputHash + ? "passed" + : "failed", + updatedAt: Date.now(), + }) + .where(key) + .run(); + }); +} + +export async function reconcileLegacyEnvironmentSetupOutcome( + deps: WorkSessionDeps, + args: { hostId: string; path: string }, +): Promise { + const key = and( + eq(environmentSetupOutcomes.hostId, args.hostId), + eq(environmentSetupOutcomes.path, args.path), + ); + if (deps.db.select().from(environmentSetupOutcomes).where(key).get()) return; + const hookKey = and( + eq(environmentHookOperations.hostId, args.hostId), + eq(environmentHookOperations.path, args.path), + eq(environmentHookOperations.kind, "setup"), + ); + const hook = deps.db + .select() + .from(environmentHookOperations) + .where(hookKey) + .orderBy(desc(environmentHookOperations.startedAt)) + .limit(1) + .get(); + if (!hook || hook.finishedAt === null || hook.error !== null) return; + const identity = { ...args, operationId: hook.operationId }; + const facts = await inspect(deps, identity); + if (facts === null || ("dirty" in facts && facts.dirty.length > 0)) return; + const inputHash = environmentSetupInputHash(facts); + const checked = await inspect(deps, identity); + if ( + checked === null || + environmentSetupInputHash(checked) !== inputHash || + ("dirty" in checked && checked.dirty.length > 0) + ) + return; + deps.db.transaction((tx) => { + const latest = tx + .select() + .from(environmentHookOperations) + .where(hookKey) + .orderBy(desc(environmentHookOperations.startedAt)) + .limit(1) + .get(); + if ( + latest?.operationId !== hook.operationId || + latest.finishedAt === null || + latest.error !== null + ) + return; + tx.insert(environmentSetupOutcomes) + .values({ + ...identity, + state: "passed", + inputHash, + updatedAt: Date.now(), + }) + .onConflictDoNothing() + .run(); + }); +} 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..77f3709692 --- /dev/null +++ b/apps/server/src/services/hosts/host-environment.test.ts @@ -0,0 +1,77 @@ +import { + createConnection, + migrate, + upsertHost, + noopNotifier, + getHost, +} 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/0114_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", + secret: false, + 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", + secret: true, + }), + ]), + ); + expect( + await resolveHostEnvironment(deps, { + hostId: "local-daemon", + projectId: null, + }), + ).toEqual([]); + expect( + db.$client.prepare("SELECT COUNT(*) AS n FROM machine_enrollments").get(), + ).toEqual({ n: 0 }); + } 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..7e6f5ca03a --- /dev/null +++ b/apps/server/src/services/hosts/host-environment.ts @@ -0,0 +1,60 @@ +import { 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 = 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..77ff4340e6 100644 --- a/apps/server/src/services/hosts/host-lifecycle.ts +++ b/apps/server/src/services/hosts/host-lifecycle.ts @@ -1,7 +1,13 @@ +import { + observeMachineLifecycle, + assertMachineLifecycleAdmission, + waitForMachineMaintenance, +} 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 } from "../machines/provider-orchestration.js"; export async function ensureHostSessionReadyForWork( deps: WorkSessionDeps, @@ -12,5 +18,26 @@ export async function ensureHostSessionReadyForWork( throw new ApiError(404, "host_not_found", "Host not found"); } + if (host.removalStartedAt !== null) { + throw new ApiError( + 409, + "machine_removing", + "Machine removal has begun; wait for a replacement machine", + ); + } + + await observeMachineLifecycle(deps, host.id); + await waitForMachineMaintenance(deps, host.id); + await resumeMachine(deps, host.id); + assertMachineLifecycleAdmission(deps, host.id); + const current = getHost(deps.db, host.id); + if (current?.removalStartedAt !== null) { + 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.ts b/apps/server/src/services/hosts/live-command.ts index 05ee6d5b64..6999b74eb0 100644 --- a/apps/server/src/services/hosts/live-command.ts +++ b/apps/server/src/services/hosts/live-command.ts @@ -18,7 +18,10 @@ 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, + callHostOnlineRpcWithoutAdmission, +} from "./online-rpc.js"; export const LIVE_DAEMON_COMMAND_TIMEOUT_MS = 24 * 60 * 60 * 1000; @@ -225,7 +228,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" + ? callHostOnlineRpcWithoutAdmission + : callHostOnlineRpc; + 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..5e07f5ac36 100644 --- a/apps/server/src/services/hosts/online-rpc.ts +++ b/apps/server/src/services/hosts/online-rpc.ts @@ -1,3 +1,8 @@ +import { + getMachineLifecycle, + assertMachineLifecycleAdmission, +} from "../machines/lifecycle.js"; +import { getHost, getThread } from "@bb/db"; import { randomUUID } from "node:crypto"; import { type HostDaemonOnlineRpcResponseMessage, @@ -40,6 +45,23 @@ export async function callHostOnlineRpc( args: CallHostOnlineRpcArgs, ): Promise { return callHostOnlineRpcWithRetry(deps, args, { + admitWork: true, + retryOnTransportFailure: false, + }); +} + +export function callHostOnlineRpcWithoutAdmission< + TCommand extends HostDaemonRpcCommand, +>( + deps: WorkSessionDeps, + args: CallHostOnlineRpcArgs, +): Promise>; +export async function callHostOnlineRpcWithoutAdmission( + deps: WorkSessionDeps, + args: CallHostOnlineRpcArgs, +): Promise { + return callHostOnlineRpcWithRetry(deps, args, { + admitWork: false, retryOnTransportFailure: false, }); } @@ -55,6 +77,27 @@ export async function callHostRetryableOnlineRpc( args: CallHostRetryableOnlineRpcArgs, ): Promise { return callHostOnlineRpcWithRetry(deps, args, { + admitWork: true, + retryOnTransportFailure: true, + }); +} + +export function callHostRetryableOnlineRpcWithoutAdmission< + TCommand extends HostDaemonRetryableOnlineRpcCommand, +>( + deps: WorkSessionDeps, + args: CallHostRetryableOnlineRpcArgs, +): Promise>; +export async function callHostRetryableOnlineRpcWithoutAdmission( + deps: WorkSessionDeps, + args: CallHostRetryableOnlineRpcArgs, +): Promise { + const host = getHost(deps.db, args.hostId); + if (host !== null && host.phase !== "active") { + throw new ApiError(502, "host_unavailable", "Host is not connected", false); + } + return callHostOnlineRpcWithRetry(deps, args, { + admitWork: false, retryOnTransportFailure: true, }); } @@ -62,29 +105,48 @@ export async function callHostRetryableOnlineRpc( async function callHostOnlineRpcWithRetry( deps: WorkSessionDeps, args: CallHostOnlineRpcArgs, - options: { retryOnTransportFailure: false }, + options: { admitWork: boolean; retryOnTransportFailure: false }, ): Promise; async function callHostOnlineRpcWithRetry( deps: WorkSessionDeps, args: CallHostRetryableOnlineRpcArgs, - options: { retryOnTransportFailure: true }, + options: { admitWork: boolean; retryOnTransportFailure: true }, ): Promise; async function callHostOnlineRpcWithRetry( deps: WorkSessionDeps, args: CallHostOnlineRpcArgs, - options: { retryOnTransportFailure: boolean }, + options: { admitWork: boolean; retryOnTransportFailure: boolean }, ): Promise { - await ensureHostSessionReadyForWork(deps, { hostId: args.hostId }).catch( - async (error) => { - if ( - !options.retryOnTransportFailure || - !isHostUnavailableApiError(error) - ) { - throw error; - } - await waitForRetryableHostRpcTransport(deps, args.hostId); - }, - ); + if (options.admitWork) { + await ensureHostSessionReadyForWork(deps, { hostId: args.hostId }).catch( + async (error) => { + if ( + !options.retryOnTransportFailure || + !isHostUnavailableApiError(error) + ) { + throw error; + } + await waitForRetryableHostRpcTransport(deps, args.hostId); + }, + ); + } + if (options.admitWork) { + assertMachineLifecycleAdmission(deps, args.hostId); + if ( + (args.command.type === "thread.start" || + args.command.type === "turn.submit") && + getMachineLifecycle(deps, args.hostId) !== undefined && + !["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", + ); + } + } const timeoutRetryDeadline = options.retryOnTransportFailure && args.timeoutMs > 1 ? Date.now() + args.timeoutMs @@ -104,6 +166,7 @@ async function callHostOnlineRpcWithRetry( throwOnlineRpcError(error); } if (error instanceof HostOnlineRpcUnavailableError) { + if (!options.admitWork) 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..58960e5d15 100644 --- a/apps/server/src/services/lib/entity-lookup.ts +++ b/apps/server/src/services/lib/entity-lookup.ts @@ -79,7 +79,24 @@ function toHostRecord(row: HostRow, status: Host["status"]): Host { id: row.id, name: row.name, status, - type: row.type, + machineProviderId: row.machineProviderId, + machineProviderSelection: row.machineProviderSelection, + lifecycle: { + phase: row.phase === "suspending" ? "active" : row.phase, + suspendedAt: row.suspendedAt, + retireAt: row.retireAt, + progress: row.teardownStatus === null ? row.teardownMessage : null, + teardown: + row.teardownStatus === null + ? null + : { + status: row.teardownStatus, + attempt: row.teardownAttempt, + ...(row.teardownMessage === null + ? {} + : { message: row.teardownMessage }), + }, + }, maxPermissionMode: row.maxPermissionMode, lastSeenAt: row.lastSeenAt, lastRejectedProtocolVersion: row.lastRejectedProtocolVersion, 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..3da187407d --- /dev/null +++ b/apps/server/src/services/machines/bootstrap.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; +import type { + MachineEnrollments, + EnrollmentBootstrap, +} from "@get-bb/plugin-sdk"; +import { createMachineBootstrapApi } from "./bootstrap.js"; + +const bootstrap: EnrollmentBootstrap = { + version: 2, + hostId: "host_1", + serverUrl: "https://server.example", + credential: "private-credential", + expiresAt: Date.now() + 60_000, +}; + +function harness() { + const enrollments: MachineEnrollments = { + prepare: vi.fn(async () => ({ + id: "enrollment", + hostId: "host_1", + state: "pending", + bootstrap, + expiresAt: bootstrap.expiresAt, + })), + waitForConnection: vi.fn(async () => ({ hostId: "host_1" })), + cancel: vi.fn(), + }; + const api = createMachineBootstrapApi(enrollments); + const exec = vi.fn(async () => ({ exitCode: 0, stdout: "", stderr: "" })); + const report = { step: vi.fn(), log: vi.fn() }; + return { api, enrollments, exec, report }; +} + +describe("machine bootstrap", () => { + it("delivers credentials only through stdin and never reports executor output", async () => { + const h = harness(); + h.exec.mockResolvedValue({ + exitCode: 0, + stdout: bootstrap.credential, + stderr: bootstrap.credential, + }); + await h.api.bootstrap({ + key: "key", + executor: { exec: h.exec }, + daemon: { kind: "install" }, + 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).not.toHaveBeenCalled(); + expect(h.enrollments.waitForConnection).toHaveBeenCalledOnce(); + expect(h.api.installerCommand(bootstrap).command.join(" ")).not.toContain( + bootstrap.credential, + ); + }); + + it("redacts transport failures and retains enrollment for retry", async () => { + const h = harness(); + h.exec.mockRejectedValue(new Error(bootstrap.credential)); + await expect( + h.api.bootstrap({ + key: "key", + executor: { exec: h.exec }, + daemon: { kind: "preinstalled" }, + report: h.report, + signal: new AbortController().signal, + }), + ).rejects.toThrow(/^Machine bootstrap command failed$/); + expect(h.enrollments.waitForConnection).not.toHaveBeenCalled(); + expect(h.enrollments.cancel).not.toHaveBeenCalled(); + }); + + it("starts an already enrolled machine before waiting after snapshot restore", async () => { + const h = harness(); + vi.mocked(h.enrollments.prepare).mockResolvedValue({ + id: "enrollment", + hostId: "host_1", + state: "enrolled", + }); + await h.api.bootstrap({ + key: "key", + executor: { exec: h.exec }, + daemon: { kind: "preinstalled" }, + report: h.report, + signal: new AbortController().signal, + }); + expect(h.exec).toHaveBeenCalledWith( + expect.objectContaining({ command: expect.arrayContaining(["host_1"]) }), + ); + 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 }, + daemon: { kind: "install" }, + report: h.report, + signal: AbortSignal.abort(), + }), + ).rejects.toThrow(); + expect(h.enrollments.prepare).not.toHaveBeenCalled(); + expect(h.exec).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/services/machines/bootstrap.ts b/apps/server/src/services/machines/bootstrap.ts new file mode 100644 index 0000000000..d85b4eb7e1 --- /dev/null +++ b/apps/server/src/services/machines/bootstrap.ts @@ -0,0 +1,111 @@ +import type { + EnrollmentBootstrap, + MachineBootstrapApi, + MachineEnrollments, + MachineInstallerCommand, +} from "@get-bb/plugin-sdk"; + +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 +`; + +const preinstalledScript = ` +set -eu +BB_ENROLLMENT=$(cat) +export BB_ENROLLMENT +bb_bin="$HOME/.local/bin/bb" +if [ ! -x "$bb_bin" ]; then bb_bin=$(command -v bb); fi +"$bb_bin" machine enroll --bootstrap-env BB_ENROLLMENT +unset BB_ENROLLMENT +"$bb_bin" machine start --host-id "$1" +`; + +export function installerCommand( + bootstrap: EnrollmentBootstrap, +): MachineInstallerCommand { + return { + command: [ + "sh", + "-c", + installerScript, + "bb-machine-install", + new URL("/install.sh", bootstrap.serverUrl).href, + ], + stdin: JSON.stringify(bootstrap), + }; +} + +export function createMachineBootstrapApi( + enrollments: MachineEnrollments, +): MachineBootstrapApi { + return { + enrollments, + prepareEnrollment: enrollments.prepare, + waitForConnection: enrollments.waitForConnection, + installerCommand, + async bootstrap(request) { + request.signal.throwIfAborted(); + request.report.step("Preparing machine enrollment"); + const enrollment = await enrollments.prepare({ + key: request.key, + access: request.access, + }); + request.signal.throwIfAborted(); + request.report.step( + enrollment.state === "enrolled" + ? "Starting enrolled machine" + : "Bootstrapping machine", + ); + const execution = + enrollment.state === "enrolled" + ? { + command: [ + "sh", + "-c", + 'bb_bin="$HOME/.local/bin/bb"; if [ ! -x "$bb_bin" ]; then bb_bin=$(command -v bb); fi; exec "$bb_bin" machine start --host-id "$1"', + "bb-machine-start", + enrollment.hostId, + ], + } + : request.daemon.kind === "install" + ? installerCommand(enrollment.bootstrap) + : { + command: [ + "sh", + "-c", + preinstalledScript, + "bb-machine-bootstrap", + enrollment.hostId, + ], + stdin: JSON.stringify(enrollment.bootstrap), + }; + try { + const result = await request.executor.exec({ + ...execution, + timeoutMs: 600_000, + signal: request.signal, + }); + if (result.exitCode !== 0) + throw new Error("Machine bootstrap command failed"); + } catch { + request.signal.throwIfAborted(); + throw new Error("Machine bootstrap command failed"); + } + request.signal.throwIfAborted(); + request.report.step("Waiting for machine connection"); + return enrollments.waitForConnection({ + enrollmentId: enrollment.id, + timeoutMs: 120_000, + signal: request.signal, + }); + }, + }; +} 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..f14d719d9b --- /dev/null +++ b/apps/server/src/services/machines/enrollments.test.ts @@ -0,0 +1,357 @@ +import { createCipheriv, randomBytes } from "node:crypto"; +import { readFile } from "node:fs/promises"; +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, machineEnrollments } from "@bb/db"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createMachineAuthService } from "../machine-auth.js"; +import { createMachineEnrollmentService } from "./enrollments.js"; + +const cleanup: Array<() => Promise> = []; +afterEach(async () => { + for (const dispose of cleanup.splice(0)) await dispose(); +}); + +async function harness() { + const dataDir = await mkdtemp(join(tmpdir(), "bb-enrollments-test-")); + const db = createConnection(":memory:"); + migrate(db); + cleanup.push(async () => { + db.$client.close(); + await rm(dataDir, { recursive: true, force: true }); + }); + const machineAuth = await createMachineAuthService({ + db, + dataDir, + logger: { debug() {}, info() {}, warn() {}, error() {} }, + }); + const serverAccess = { + resolve: vi.fn(async () => ({ + id: "grant", + serverUrl: "https://server.example", + })), + release: vi.fn(async () => {}), + }; + const connected = new Set(); + const deps = { + dataDir, + db, + machineAuth, + serverAccess, + isConnected: (id: string) => connected.has(id), + }; + const create = () => createMachineEnrollmentService(deps); + const service = create(); + return { + ...deps, + connected, + create, + api: service.forOwner("plugin-a"), + other: service.forOwner("plugin-b"), + }; +} + +describe("machine enrollments", () => { + it("serializes same-key prepares and preserves host identity across restart without storing credentials", async () => { + const h = await harness(); + const [first, parallel] = await Promise.all([ + h.api.prepare({ key: "create" }), + h.api.prepare({ key: "create" }), + ]); + expect(parallel).toEqual(first); + expect(h.serverAccess.resolve).toHaveBeenCalledOnce(); + const restarted = await h + .create() + .forOwner("plugin-a") + .prepare({ key: "create" }); + expect(restarted.id).toBe(first.id); + expect(restarted.hostId).toBe(first.hostId); + expect(first.state).toBe("pending"); + if (first.state !== "pending" || restarted.state !== "pending") + throw new Error("Expected pending enrollment"); + expect(restarted.bootstrap.credential).toBe(first.bootstrap.credential); + expect( + JSON.stringify(h.db.select().from(machineEnrollments).all()), + ).not.toContain(first.bootstrap.credential); + }); + + it("rejects conflicting access selection even when a bundle is cached", async () => { + const h = await harness(); + const prepared = await h.api.prepare({ key: "access" }); + h.db.$client + .prepare("UPDATE hosts SET server_access_provider_id = ? WHERE id = ?") + .run("direct", prepared.hostId); + await expect( + h.api.prepare({ key: "access", access: { providerId: "connect" } }), + ).rejects.toThrow("different server access provider"); + }); + + it("reissues an expired pending credential and rejects the previous one", async () => { + const h = await harness(); + const prepared = await h.api.prepare({ key: "expiry" }); + if (prepared.state !== "pending") + throw new Error("Expected pending enrollment"); + h.db + .update(machineEnrollments) + .set({ expiresAt: 1 }) + .where(eq(machineEnrollments.id, prepared.id)) + .run(); + const renewed = await h.api.prepare({ key: "expiry" }); + if (renewed.state !== "pending") + throw new Error("Expected pending enrollment"); + expect(renewed.hostId).toBe(prepared.hostId); + expect(renewed.bootstrap.credential).not.toBe( + prepared.bootstrap.credential, + ); + expect( + await h.machineAuth.enrollHost({ + hostId: prepared.hostId, + token: prepared.bootstrap.credential, + allowPublicEnrollment: true, + }), + ).toBeNull(); + }); + + it("fails closed on encrypted bundle corruption and removed identities", async () => { + const h = await harness(); + const prepared = await h.api.prepare({ key: "corrupt" }); + h.db + .update(machineEnrollments) + .set({ encryptedBootstrap: "invalid" }) + .where(eq(machineEnrollments.id, prepared.id)) + .run(); + await expect(h.api.prepare({ key: "corrupt" })).rejects.toThrow( + "Could not recover", + ); + h.db + .update(hosts) + .set({ phase: "destroyed" }) + .where(eq(hosts.id, prepared.hostId)) + .run(); + await expect(h.api.prepare({ key: "corrupt" })).rejects.toThrow("removed"); + }); + + it("preserves runtime access when exchange wins a cancellation race", async () => { + const h = await harness(); + const prepared = await h.api.prepare({ key: "race" }); + if (prepared.state !== "pending") + throw new Error("Expected pending enrollment"); + const [result] = await Promise.all([ + h.machineAuth.enrollHost({ + hostId: prepared.hostId, + token: prepared.bootstrap.credential, + allowPublicEnrollment: true, + }), + h.api.cancel({ enrollmentId: prepared.id }), + ]); + expect(result).not.toBeNull(); + expect(h.serverAccess.release).not.toHaveBeenCalled(); + expect(await h.api.prepare({ key: "race" })).toMatchObject({ + id: prepared.id, + hostId: prepared.hostId, + state: "pending", + }); + }); + + it("recovers enrolled state after an authenticated connection and rejects credential replay", async () => { + const h = await harness(); + const prepared = await h.api.prepare({ key: "create" }); + if (prepared.state !== "pending") + throw new Error("Expected pending enrollment"); + const request = { + hostId: prepared.hostId, + token: prepared.bootstrap.credential, + allowPublicEnrollment: true, + }; + const result = await h.machineAuth.enrollHost(request); + expect(result).not.toBeNull(); + expect(await h.machineAuth.enrollHost(request)).toBeNull(); + h.db.update(hosts).set({ lastSeenAt: Date.now() }) + .where(eq(hosts.id, prepared.hostId)).run(); + const restarted = h.create().forOwner("plugin-a"); + expect(await restarted.prepare({ key: "create" })).toEqual({ + id: prepared.id, + hostId: prepared.hostId, + state: "enrolled", + }); + await restarted.cancel({ enrollmentId: prepared.id }); + expect( + await h.machineAuth.verifyDaemonHostKey(result!.hostKey), + ).not.toBeNull(); + }); + + it("recovers a lost exchange response with a fresh credential for the same identity", async () => { + const h = await harness(); + const first = await h.api.prepare({ key: "lost-response" }); + if (first.state !== "pending") throw new Error("Expected pending enrollment"); + const lostResponse = await h.machineAuth.enrollHost({ + token: first.bootstrap.credential, + hostId: first.hostId, + allowPublicEnrollment: true, + }); + expect(lostResponse).not.toBeNull(); + await h.machineAuth.issueHostEnrollKey({ + hostId: first.hostId, + enrollSource: "public-multi-machine", + }); + const retry = await h.create().forOwner("plugin-a").prepare({ key: "lost-response" }); + if (retry.state !== "pending") throw new Error("Expected recoverable pending enrollment"); + expect(retry.hostId).toBe(first.hostId); + expect(retry.bootstrap.credential === first.bootstrap.credential).toBe(false); + const recovered = await h.machineAuth.enrollHost({ + token: retry.bootstrap.credential, + hostId: retry.hostId, + allowPublicEnrollment: true, + }); + if (!recovered || !lostResponse) throw new Error("Expected successful exchanges"); + expect(await h.machineAuth.verifyDaemonHostKey(recovered.hostKey)).not.toBeNull(); + expect(await h.machineAuth.verifyDaemonHostKey(lostResponse.hostKey)).toBeNull(); + const beforeStart = await h.api.prepare({ key: "lost-response" }); + expect(beforeStart.state).toBe("pending"); + expect(await h.machineAuth.verifyDaemonHostKey(recovered.hostKey)).not.toBeNull(); + h.db.update(hosts).set({ lastSeenAt: Date.now() }) + .where(eq(hosts.id, first.hostId)).run(); + expect(await h.create().forOwner("plugin-a").prepare({ key: "lost-response" })).toEqual({ + id: first.id, hostId: first.hostId, state: "enrolled", + }); + }); + + it("isolates owners and cancellation revokes only the pending credential", async () => { + const h = await harness(); + const prepared = await h.api.prepare({ key: "create" }); + const other = await h.other.prepare({ key: "create" }); + expect(other.hostId).not.toBe(prepared.hostId); + await expect(h.other.cancel({ enrollmentId: prepared.id })).rejects.toThrow( + "not found", + ); + await h.api.cancel({ enrollmentId: prepared.id }); + if (prepared.state !== "pending") + throw new Error("Expected pending enrollment"); + expect( + await h.machineAuth.enrollHost({ + hostId: prepared.hostId, + token: prepared.bootstrap.credential, + allowPublicEnrollment: true, + }), + ).toBeNull(); + expect(h.serverAccess.release).toHaveBeenCalledOnce(); + const retry = await h.api.prepare({ key: "create" }); + expect(retry.hostId).toBe(prepared.hostId); + }); + + it("recovers access failures with the same durable host identity", async () => { + const h = await harness(); + h.serverAccess.resolve.mockRejectedValueOnce( + new Error("temporarily unavailable"), + ); + await expect(h.api.prepare({ key: "create" })).rejects.toThrow( + "temporarily unavailable", + ); + const row = h.db.select().from(machineEnrollments).get(); + const retry = await h.api.prepare({ key: "create" }); + expect(retry.hostId).toBe(row?.hostId); + }); + + it("bounds connection waits and rejects cancellation and abort", async () => { + const h = await harness(); + const prepared = await h.api.prepare({ key: "create" }); + const request = { + enrollmentId: prepared.id, + timeoutMs: 5, + signal: new AbortController().signal, + }; + await expect(h.api.waitForConnection(request)).rejects.toThrow("Timed out"); + await expect( + h.api.waitForConnection({ ...request, signal: AbortSignal.abort() }), + ).rejects.toThrow(); + h.connected.add(prepared.hostId); + expect(await h.api.waitForConnection(request)).toEqual({ + hostId: prepared.hostId, + }); + expect( + h.db + .select() + .from(machineEnrollments) + .where(eq(machineEnrollments.id, prepared.id)) + .get(), + ).toMatchObject({ + state: "enrolled", + encryptedBootstrap: null, + expiresAt: null, + }); + const cancelled = await h.api.prepare({ key: "cancelled" }); + await h.api.cancel({ enrollmentId: cancelled.id }); + await expect( + h.api.waitForConnection({ ...request, enrollmentId: cancelled.id }), + ).rejects.toThrow("cancelled"); + }); +}); + +it("enrollment cancellation retries a failed access release", async () => { + const h = await harness(); + const e = await h.api.prepare({ key: "release-retry" }); + h.serverAccess.release.mockRejectedValueOnce( + new Error("temporary access outage"), + ); + await expect(h.api.cancel({ enrollmentId: e.id })).rejects.toThrow( + "temporary access outage", + ); + await h.create().forOwner("plugin-a").cancel({ enrollmentId: e.id }); + expect(h.serverAccess.release).toHaveBeenCalledTimes(2); +}); + +it.each(["direct", "connect"])( + "upgrades an encrypted pending v1 %s bundle on restart", + async (kind) => { + const h = await harness(); + const first = await h.api.prepare({ key: "legacy" }); + if (first.state !== "pending") throw new Error("Expected pending"); + const legacy = { + ...first.bootstrap, + version: 1, + client: + kind === "direct" + ? { kind } + : { kind, machineCode: "legacy-code", expiresAt: first.expiresAt }, + }; + const key = Buffer.from( + ( + await readFile(join(h.dataDir, "machine-enrollment-secret"), "utf8") + ).trim(), + "hex", + ); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + cipher.setAAD(Buffer.from(first.id)); + const encrypted = Buffer.concat([ + cipher.update(JSON.stringify(legacy)), + cipher.final(), + ]); + h.db + .update(machineEnrollments) + .set({ + encryptedBootstrap: Buffer.concat([ + iv, + cipher.getAuthTag(), + encrypted, + ]).toString("base64"), + }) + .where(eq(machineEnrollments.id, first.id)) + .run(); + const second = await h + .create() + .forOwner("plugin-a") + .prepare({ key: "legacy" }); + expect(second).toMatchObject({ + id: first.id, + hostId: first.hostId, + bootstrap: { version: 2, credential: first.bootstrap.credential }, + }); + expect(JSON.stringify(second)).not.toContain("client"); + expect(h.serverAccess.resolve).toHaveBeenCalledTimes(2); + await h.create().forOwner("plugin-a").prepare({ key: "legacy" }); + expect(h.serverAccess.resolve).toHaveBeenCalledTimes(2); + }, +); diff --git a/apps/server/src/services/machines/enrollments.ts b/apps/server/src/services/machines/enrollments.ts new file mode 100644 index 0000000000..56943ab00f --- /dev/null +++ b/apps/server/src/services/machines/enrollments.ts @@ -0,0 +1,572 @@ +import { defaultKeyHasher } from "@better-auth/api-key"; +import { getMachineProvider } from "../plugins/plugin-machine-provider-registry.js"; +import { z } from "zod"; +import { readOrCreateSecretFile } from "@bb/secret-storage"; +import { + createCipheriv, + createDecipheriv, + randomBytes, + randomUUID, +} from "node:crypto"; +import { setTimeout as delay } from "node:timers/promises"; +import { and, eq, gt, sql } from "drizzle-orm"; +import { + authApiKeys, + createHostId, + hosts, + machineEnrollments, + machineLaunches, + type DbConnection, +} from "@bb/db"; +import type { + EnrollmentBootstrap, + MachineEnrollments, + MachineEnrollment, + ServerAccessGrant, + ServerAccessSelection, +} from "@get-bb/plugin-sdk"; +import type { MachineAuthService } from "../machine-auth.js"; + +interface EnrollmentServiceDependencies { + db: DbConnection; + dataDir: string; + machineAuth: MachineAuthService; + serverAccess: { + resolve(request: { + key: string; + hostId: string; + access?: ServerAccessSelection; + signal: AbortSignal; + }): Promise; + release(request: { + key: string; + hostId: string; + signal: AbortSignal; + }): Promise; + }; + isConnected(hostId: string): boolean; +} + +export function createMachineEnrollmentService( + deps: EnrollmentServiceDependencies, +) { + let encryptionKey: Promise | null = null; + function key(): Promise { + encryptionKey ??= readOrCreateSecretFile({ + dataDir: deps.dataDir, + fileName: "machine-enrollment-secret", + bytes: 32, + encoding: "hex", + }) + .then((value) => Buffer.from(value, "hex")) + .catch((error) => { + encryptionKey = null; + throw error; + }); + return encryptionKey; + } + async function seal( + id: string, + bootstrap: EnrollmentBootstrap, + ): Promise { + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", await key(), iv); + cipher.setAAD(Buffer.from(id)); + const encrypted = Buffer.concat([ + cipher.update(JSON.stringify(bootstrap), "utf8"), + cipher.final(), + ]); + return Buffer.concat([iv, cipher.getAuthTag(), encrypted]).toString( + "base64", + ); + } + async function open(id: string, ciphertext: string) { + try { + const bytes = Buffer.from(ciphertext, "base64"); + const decipher = createDecipheriv( + "aes-256-gcm", + await key(), + bytes.subarray(0, 12), + ); + decipher.setAAD(Buffer.from(id)); + decipher.setAuthTag(bytes.subarray(12, 28)); + const plain = Buffer.concat([ + decipher.update(bytes.subarray(28)), + decipher.final(), + ]).toString("utf8"); + const fields = { + hostId: z.string().min(1), + serverUrl: z.string().url(), + credential: z.string().min(1), + expiresAt: z.number().positive(), + }; + return z + .discriminatedUnion("version", [ + z.strictObject({ + ...fields, + version: z.literal(1), + client: z.discriminatedUnion("kind", [ + z.strictObject({ kind: z.literal("direct") }), + z.strictObject({ + kind: z.literal("connect"), + machineCode: z.string().min(1), + expiresAt: z.number().positive(), + }), + ]), + }), + z.strictObject({ + ...fields, + version: z.literal(2), + headers: z.record(z.string(), z.string()).optional(), + }), + ]) + .parse(JSON.parse(plain)); + } catch { + throw new Error("Could not recover pending machine enrollment"); + } + } + 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 rowForId(id: string) { + const row = deps.db + .select() + .from(machineEnrollments) + .where( + and( + eq(machineEnrollments.id, id), + eq(machineEnrollments.owner, owner), + ), + ) + .get(); + if (!row) throw new Error("Machine enrollment was not found"); + return row; + } + return { + async prepare(request) { + if (!request.key.trim()) + throw new Error("Machine enrollment key must not be empty"); + const lockKey = JSON.stringify([owner, request.key]); + return serialized(lockKey, async () => { + const now = Date.now(); + const row = deps.db.transaction((tx) => { + const launch = tx + .select({ + providerId: machineLaunches.providerId, + hostId: machineLaunches.hostId, + attempt: machineLaunches.attempt, + }) + .from(machineLaunches) + .where(eq(machineLaunches.key, request.key)) + .get(); + if ( + launch && + getMachineProvider(launch.providerId)?.pluginId !== owner + ) + throw new Error("Machine launch belongs to a different plugin"); + tx.insert(machineEnrollments) + .values({ + id: randomUUID(), + owner, + key: request.key, + hostId: launch?.hostId ?? createHostId(), + state: "pending", + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .run(); + const enrollment = tx + .select() + .from(machineEnrollments) + .where( + and( + eq(machineEnrollments.owner, owner), + eq(machineEnrollments.key, request.key), + ), + ) + .get(); + if (!enrollment) + throw new Error("Machine enrollment could not be prepared"); + if (launch) { + if (launch.hostId !== null && launch.hostId !== enrollment.hostId) + throw new Error( + "Machine launch already has a different host identity", + ); + tx.update(machineLaunches) + .set({ hostId: enrollment.hostId }) + .where( + and( + eq(machineLaunches.key, request.key), + eq(machineLaunches.providerId, launch.providerId), + eq(machineLaunches.attempt, launch.attempt), + ), + ) + .run(); + } + return enrollment; + }); + const host = deps.db + .select({ + phase: hosts.phase, + lastSeenAt: hosts.lastSeenAt, + accessProviderId: hosts.serverAccessProviderId, + }) + .from(hosts) + .where(eq(hosts.id, row.hostId)) + .get(); + if ( + request.access && + host?.accessProviderId && + request.access.providerId !== host.accessProviderId + ) + throw new Error( + "Machine enrollment already uses a different server access provider", + ); + if ( + host?.phase === "destroyed" || + (row.state === "enrolled" && !host) + ) + throw new Error( + "Machine enrollment identity has been removed; use a new creation key", + ); + if ( + (host && host.lastSeenAt !== null) || + deps.isConnected(row.hostId) + ) { + deps.db + .update(machineEnrollments) + .set({ + state: "enrolled", + encryptedBootstrap: null, + expiresAt: null, + updatedAt: now, + }) + .where(eq(machineEnrollments.id, row.id)) + .run(); + return { id: row.id, hostId: row.hostId, state: "enrolled" }; + } + if ( + row.encryptedBootstrap && + row.expiresAt !== null && + row.expiresAt > now && + row.state === "pending" + ) { + const bootstrap = await open(row.id, row.encryptedBootstrap); + if ( + bootstrap.hostId !== row.hostId || + bootstrap.expiresAt !== row.expiresAt + ) + throw new Error("Pending machine enrollment identity is invalid"); + if ( + await hasUnusedEnrollmentCredential( + row.hostId, + bootstrap.credential, + now, + ) + ) { + const grant = + bootstrap.version === 1 + ? await deps.serverAccess.resolve({ + key: lockKey, + hostId: row.hostId, + access: request.access, + signal: AbortSignal.timeout(60_000), + }) + : { + serverUrl: bootstrap.serverUrl, + headers: bootstrap.headers, + }; + const upgraded: EnrollmentBootstrap = { + version: 2, + hostId: bootstrap.hostId, + serverUrl: grant.serverUrl, + ...(grant.headers === undefined + ? {} + : { headers: grant.headers }), + credential: bootstrap.credential, + expiresAt: bootstrap.expiresAt, + }; + if (bootstrap.version === 1) { + deps.db + .update(machineEnrollments) + .set({ + encryptedBootstrap: await seal(row.id, upgraded), + updatedAt: now, + }) + .where(eq(machineEnrollments.id, row.id)) + .run(); + } + return { + id: row.id, + hostId: row.hostId, + state: "pending", + bootstrap: upgraded, + expiresAt: row.expiresAt, + }; + } + } + deps.db + .insert(hosts) + .values({ + id: row.hostId, + name: row.hostId, + createdAt: now, + updatedAt: now, + }) + .onConflictDoNothing() + .run(); + const grant = await deps.serverAccess.resolve({ + key: lockKey, + hostId: row.hostId, + access: request.access, + signal: AbortSignal.timeout(60_000), + }); + const credential = await deps.machineAuth.issueHostEnrollKey({ + hostId: row.hostId, + enrollSource: "public-multi-machine", + }); + const expiresAt = credential.expiresAt; + const result: Extract = { + id: row.id, + hostId: row.hostId, + state: "pending", + expiresAt, + bootstrap: { + version: 2, + hostId: row.hostId, + serverUrl: grant.serverUrl, + ...(grant.headers === undefined + ? {} + : { headers: grant.headers }), + credential: credential.key, + expiresAt, + }, + }; + deps.db + .update(machineEnrollments) + .set({ + state: "pending", + encryptedBootstrap: await seal(row.id, result.bootstrap), + expiresAt, + updatedAt: Date.now(), + }) + .where(eq(machineEnrollments.id, row.id)) + .run(); + 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 row = rowForId(enrollmentId); + if (row.state === "cancelled") + throw new Error("Machine enrollment was cancelled"); + if (deps.isConnected(row.hostId)) { + deps.db + .update(machineEnrollments) + .set({ + state: "enrolled", + encryptedBootstrap: null, + expiresAt: null, + updatedAt: Date.now(), + }) + .where(eq(machineEnrollments.id, row.id)) + .run(); + return { hostId: row.hostId }; + } + 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 cancel({ enrollmentId }) { + const initial = rowForId(enrollmentId); + const key = JSON.stringify([owner, initial.key]); + await serialized(key, async () => { + const row = rowForId(enrollmentId); + if (row.state === "cancelled") { + await deps.serverAccess.release({ + key, + hostId: row.hostId, + signal: AbortSignal.timeout(60_000), + }); + return; + } + if ( + row.state === "enrolled" || + hasIssuedDaemonCredential(row.hostId) + ) { + deps.db + .update(machineEnrollments) + .set({ + state: "enrolled", + encryptedBootstrap: null, + expiresAt: null, + updatedAt: Date.now(), + }) + .where(eq(machineEnrollments.id, row.id)) + .run(); + return; + } + await deps.machineAuth.revokeHostEnrollKeys({ hostId: row.hostId }); + if (hasIssuedDaemonCredential(row.hostId)) return; + deps.db + .update(machineEnrollments) + .set({ + state: "cancelled", + encryptedBootstrap: null, + expiresAt: null, + updatedAt: Date.now(), + }) + .where(eq(machineEnrollments.id, row.id)) + .run(); + await deps.serverAccess.release({ + key, + hostId: row.hostId, + signal: AbortSignal.timeout(60_000), + }); + }); + }, + }; + } + return { + forOwner: scoped, + async pendingBootstrapForLaunch( + launchId: string, + ): Promise { + const read = () => + deps.db + .select({ enrollment: machineEnrollments, launch: machineLaunches }) + .from(machineEnrollments) + .innerJoin( + machineLaunches, + and( + eq(machineEnrollments.key, machineLaunches.key), + eq(machineEnrollments.hostId, machineLaunches.hostId), + ), + ) + .where( + and( + eq(machineLaunches.key, launchId), + eq(machineLaunches.providerId, "manual"), + eq(machineLaunches.phase, "creating"), + eq(machineLaunches.cancelPending, false), + eq(machineEnrollments.state, "pending"), + gt(machineEnrollments.expiresAt, Date.now()), + ), + ) + .get(); + const row = read(); + if ( + !row?.enrollment.encryptedBootstrap || + row.enrollment.owner !== getMachineProvider("manual")?.pluginId || + deps.isConnected(row.enrollment.hostId) || + hasIssuedDaemonCredential(row.enrollment.hostId) + ) + return null; + const bootstrap = await open( + row.enrollment.id, + row.enrollment.encryptedBootstrap, + ); + if ( + !(await hasUnusedEnrollmentCredential( + row.enrollment.hostId, + bootstrap.credential, + Date.now(), + )) + ) + return null; + const current = read(); + if ( + current?.enrollment.encryptedBootstrap !== + row.enrollment.encryptedBootstrap || + deps.isConnected(row.enrollment.hostId) || + hasIssuedDaemonCredential(row.enrollment.hostId) + ) + return null; + return bootstrap.version === 2 ? bootstrap : null; + }, + async cancelByKey( + owner: string, + key: string, + ): Promise<{ hostId: string } | null> { + const row = deps.db + .select({ + id: machineEnrollments.id, + hostId: machineEnrollments.hostId, + }) + .from(machineEnrollments) + .where( + and( + eq(machineEnrollments.owner, owner), + eq(machineEnrollments.key, key), + ), + ) + .get(); + if (!row) return null; + await scoped(owner).cancel({ enrollmentId: row.id }); + return { hostId: row.hostId }; + }, + }; +} 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..efc906be4a --- /dev/null +++ b/apps/server/src/services/machines/environment-settings.ts @@ -0,0 +1,155 @@ +import { machineGitHealth } from "./git-credentials.js"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { eq, like } from "drizzle-orm"; +import { appSettingsValues, type DbConnection } from "@bb/db"; +import { deleteSecretFile, writeSecretFile } from "@bb/secret-storage"; +import { + machineEnvironmentNameSchema, + machineEnvironmentVariableSchema, + type MachineEnvironmentSet, + type MachineEnvironmentVariable, +} from "@bb/server-contract"; +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; + +const prefix = "machineEnvironment:"; +const locks = new WeakMap>>(); + +function secretPath(dataDir: string, name: string): string { + return join( + dataDir, + "secrets", + "machine-environment", + machineEnvironmentNameSchema.parse(name), + ); +} + +export function listMachineEnvironment( + db: DbConnection, +): MachineEnvironmentVariable[] { + return db + .select({ value: appSettingsValues.value }) + .from(appSettingsValues) + .where(like(appSettingsValues.key, `${prefix}%`)) + .orderBy(appSettingsValues.key) + .all() + .map((row) => + machineEnvironmentVariableSchema.parse(JSON.parse(row.value)), + ); +} + +export async function updateMachineEnvironment( + db: DbConnection, + dataDir: string, + name: string, + input: MachineEnvironmentSet | null, +): Promise { + name = machineEnvironmentNameSchema.parse(name); + let pending = locks.get(db); + if (!pending) { + pending = new Map(); + locks.set(db, pending); + } + const previous = pending.get(name) ?? Promise.resolve(); + const current = previous + .catch(() => {}) + .then(async () => { + const path = secretPath(dataDir, name); + if (input === null) { + db.delete(appSettingsValues) + .where(eq(appSettingsValues.key, prefix + name)) + .run(); + await deleteSecretFile(path); + return; + } + const secret = input.secret || name === "GH_TOKEN"; + if (secret) await writeSecretFile(path, input.value); + const value = JSON.stringify({ + name, + value: secret ? null : input.value, + secret, + note: input.note, + }); + const updatedAt = Date.now(); + db.insert(appSettingsValues) + .values({ key: prefix + name, value, updatedAt }) + .onConflictDoUpdate({ + target: appSettingsValues.key, + set: { value, updatedAt }, + }) + .run(); + if (!secret) await deleteSecretFile(path); + }); + pending.set(name, current); + try { + await current; + } finally { + if (pending.get(name) === current) pending.delete(name); + } +} + +export async function resolveUserMachineEnvironment( + db: DbConnection, + dataDir: string, +): Promise { + const rows = listMachineEnvironment(db); + return Promise.all( + rows.map(async (row) => { + let value = row.value; + if (row.secret) { + try { + value = await readFile(secretPath(dataDir, row.name), "utf8"); + } catch { + throw new Error( + `Machine environment secret ${row.name} is unavailable; set it again`, + ); + } + } + if (value === null) + throw new Error( + `Machine environment variable ${row.name} has no value`, + ); + return { + name: row.name, + value, + secret: row.secret, + reason: row.note ?? "Machine environment setting", + source: { core: "machine-environment" }, + }; + }), + ); +} + +export async function machineEnvironmentView(db: DbConnection) { + const variables = listMachineEnvironment(db); + const overridden = variables.some((row) => row.name === "GH_TOKEN"); + const health = overridden + ? { + status: "ready", + statusMessage: + "The built-in gh token is overridden by Machine environment.", + } + : await machineGitHealth(); + return { + variables, + builtInGit: { + status: overridden + ? ("overridden" as const) + : health.status === "ready" + ? ("logged in" as const) + : ("not logged in" as const), + statusMessage: health.statusMessage, + }, + }; +} + +export async function effectiveMachineGitHealth(db: DbConnection) { + const view = await machineEnvironmentView(db); + return { + status: + view.builtInGit.status === "not logged in" + ? ("not configured" as const) + : ("ready" as const), + statusMessage: view.builtInGit.statusMessage, + }; +} 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..6a210ee602 --- /dev/null +++ b/apps/server/src/services/machines/git-credentials.test.ts @@ -0,0 +1,233 @@ +import { mergeHostAndProviderEnvironment } from "../hosts/host-environment.js"; +import { createConnection, migrate, machineEnrollments } from "@bb/db"; +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, + resolveMachineGitEnv, + 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 }), + ); +} + +function database() { + const db = createConnection(":memory:"); + migrate(db); + cleanup.push(async () => { + db.$client.close(); + }); + return db; +} + +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("only contributes for enrolled machines and never queries gh for other hosts", async () => { + const db = database(); + const run = gh(); + for (const state of ["pending", "cancelled", "enrolled"] as const) { + db.insert(machineEnrollments) + .values({ + id: state, + owner: "do", + key: state, + hostId: state, + state, + createdAt: 1, + updatedAt: 1, + }) + .run(); + } + for (const hostId of ["local", "pending", "cancelled"]) + expect(await resolveMachineGitEnv(db, hostId, run)).toEqual([]); + expect(run).not.toHaveBeenCalled(); + expect(await resolveMachineGitEnv(db, "enrolled", run)).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + name: "GH_TOKEN", + value: "test-private-token", + secret: true, + }), + ]), + ); + }); + + 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", + secret: true, + }, + ]; + 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..acae49e6c9 --- /dev/null +++ b/apps/server/src/services/machines/git-credentials.ts @@ -0,0 +1,119 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; +import { and, eq } from "drizzle-orm"; +import { machineEnrollments, type DbConnection } from "@bb/db"; +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; +import { z } from "zod"; + +const exec = promisify(execFile); + +export 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(), +}); + +export function isEnrolledMachine(db: DbConnection, hostId: string): boolean { + return ( + db + .select({ id: machineEnrollments.id }) + .from(machineEnrollments) + .where( + and( + eq(machineEnrollments.hostId, hostId), + eq(machineEnrollments.state, "enrolled"), + ), + ) + .get() !== undefined + ); +} + +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", + secret: false, + }), + ); +} + +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", + secret: name === "GH_TOKEN", + })), + ]; + } catch { + return []; + } +} + +export async function resolveMachineGitEnv( + db: DbConnection, + hostId: string, + run = runGh, +): Promise { + return isEnrolledMachine(db, hostId) ? resolveGitCredentials(run) : []; +} + +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 + ? "GitHub credentials are provided by the server gh login." + : "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..cbf505991f --- /dev/null +++ b/apps/server/src/services/machines/lifecycle.ts @@ -0,0 +1,532 @@ +import { cancelPendingEnvironmentHook } from "../environments/environment-hooks.js"; +import { randomUUID } from "node:crypto"; +import { and, eq, inArray, isNull } from "drizzle-orm"; +import { z } from "zod"; +import { + environmentHookOperations, + environments, + getHost, + hosts, + machineHasLiveThreads, + machineLifecycles, + terminalSessions, + threads, + updateHost, +} from "@bb/db"; +import { jsonValueSchema } from "@bb/domain"; +import type { + experimental_HostLifecycleRequest, + experimental_HostLifecycleResponse, +} from "@bb/server-contract"; +import type { + WorkSessionDeps, + LoggedPendingInteractionWorkSessionDeps, +} from "../../types.js"; +import { ApiError } from "../../errors.js"; +import { + getMachineProvider, + invokeMachineProvider, +} from "../plugins/plugin-machine-provider-registry.js"; +import { appendSystemErrorEvent } from "../threads/thread-events.js"; +import { threadScope } from "@bb/domain"; +import { stopThreadForCurrentState } from "../threads/thread-lifecycle.js"; + +const observationSchema = z + .object({ + state: z.enum(["running", "suspended", "missing", "unknown"]), + expiresAt: z.number().finite().nullable(), + resource: jsonValueSchema.refine( + (value) => Buffer.byteLength(JSON.stringify(value)) <= 16_384, + "Resource exceeds 16 KiB", + ), + }) + .strict(); +const durationSchema = z.number().int().nonnegative().nullable(); +const policySchema = z + .object({ + idleSuspendMs: durationSchema, + retireAfterMs: durationSchema, + deadlineLeadMs: durationSchema, + }) + .strict(); +const LEASE_MS = 30_000; +const RETRY_MS = 10_000; +type Deps = Pick; + +export function getMachineLifecycle(deps: Pick, hostId: string) { + return deps.db + .select() + .from(machineLifecycles) + .where(eq(machineLifecycles.hostId, hostId)) + .get(); +} + +export async function observeMachineLifecycle( + deps: Deps, + hostId: string, +): Promise { + const host = getHost(deps.db, hostId); + if ( + host === null || + host.destroyedAt !== null || + host.machineProviderId === null || + host.resource === null + ) + return; + const resource = host.resource; + const record = getMachineProvider(host.machineProviderId); + const observe = record?.provider.experimental_observe; + const policy = record?.provider.experimental_policy; + if (record === undefined || observe === undefined || policy === undefined) + return; + deps.db + .insert(machineLifecycles) + .values({ + hostId, + observedState: "unknown", + observedAt: Date.now(), + recoveryState: "healthy", + }) + .onConflictDoNothing() + .run(); + const previous = getMachineLifecycle(deps, hostId); + if (previous?.leaseUntil != null && previous.leaseUntil > Date.now()) return; + const result = await invokeMachineProvider( + record, + "machine observation", + async () => ({ + observation: observationSchema.parse( + await observe({ + hostId, + resource, + signal: AbortSignal.timeout(10_000), + }), + ), + policy: policySchema.parse(await policy({ hostId, resource })), + }), + ); + const failed = deps.db.transaction((tx) => { + const current = getHost(tx, hostId); + const state = tx + .select() + .from(machineLifecycles) + .where(eq(machineLifecycles.hostId, hostId)) + .get(); + if ( + current === null || + current.destroyedAt !== null || + current.machineOperationId !== host.machineOperationId || + state?.leaseId !== previous?.leaseId || + JSON.stringify(current.resource) !== JSON.stringify(host.resource) || + (state?.leaseUntil != null && state.leaseUntil > Date.now()) + ) + return; + if (!result.ok) { + if (previous !== undefined) + tx.update(machineLifecycles) + .set({ + message: + previous.expiresAt !== null && previous.expiresAt <= Date.now() + ? `Vendor expiry passed while observation failed: ${result.error}. Changes since the last successful snapshot may be lost.` + : result.error, + observedState: "unknown", + ...(previous.expiresAt !== null && previous.expiresAt <= Date.now() + ? { recoveryState: "lost-since-last-snapshot" as const } + : {}), + }) + .where(eq(machineLifecycles.hostId, hostId)) + .run(); + return true; + } + const { observation, policy: effective } = result.value; + const now = Date.now(); + const unusedSince = machineHasLiveThreads(tx, hostId) + ? null + : (state?.unusedSince ?? now); + const retentionAt = + state?.keep === true || + unusedSince === null || + effective.retireAfterMs === null + ? null + : unusedSince + effective.retireAfterMs; + const lost = + observation.state === "missing" && current.suspendedAt === null; + const abandoned = + state?.leaseId != null && + (state.leaseUntil === null || state.leaseUntil <= now); + const saved = + current.suspendedAt !== null && + state?.lastSnapshotAt != null && + observation.state === "suspended"; + const reconciled = + abandoned && observation.state !== "unknown" + ? { + leaseId: null, + leaseUntil: null, + recoveryState: saved + ? ("saved" as const) + : ("recoverable" as const), + retryAt: saved ? null : now, + message: saved + ? null + : "Interrupted preservation requires recovery; the last successful save remains the recovery point.", + } + : {}; + const values = { + observedState: observation.state, + observedAt: now, + expiresAt: observation.expiresAt, + maintenanceAt: + observation.expiresAt === null || effective.deadlineLeadMs === null + ? null + : observation.expiresAt - effective.deadlineLeadMs, + ...effective, + unusedSince, + retentionAt, + ...reconciled, + ...(lost + ? { + recoveryState: "lost-since-last-snapshot" as const, + message: + "Compute disappeared before preservation completed. Changes since the last successful snapshot may be lost. Explicit recovery is required.", + } + : {}), + }; + tx.insert(machineLifecycles) + .values({ hostId, recoveryState: "healthy", ...values }) + .onConflictDoUpdate({ target: machineLifecycles.hostId, set: values }) + .run(); + tx.update(hosts) + .set({ resource: observation.resource }) + .where(eq(hosts.id, hostId)) + .run(); + }); + if (failed && !result.ok) + throw new ApiError(409, "machine_observation_failed", result.error); +} + +export function machineLifecycleStatus( + deps: Deps, + hostId: string, + input: experimental_HostLifecycleRequest, +): experimental_HostLifecycleResponse { + const host = getHost(deps.db, hostId); + if (host === null) + throw new ApiError(404, "host_not_found", "Host not found"); + if (input.keep !== undefined) { + const current = getMachineLifecycle(deps, hostId); + if (current === undefined) + throw new ApiError( + 409, + "machine_lifecycle_unavailable", + "This machine does not declare lifecycle policy", + ); + deps.db + .update(machineLifecycles) + .set({ + keep: input.keep, + retentionAt: + input.keep || + current.unusedSince === null || + current.retireAfterMs === null + ? null + : current.unusedSince + current.retireAfterMs, + }) + .where(eq(machineLifecycles.hostId, hostId)) + .run(); + } + const state = getMachineLifecycle(deps, hostId); + return { + phase: host.phase, + expiresAt: state?.expiresAt ?? null, + maintenanceAt: state?.maintenanceAt ?? null, + lastSnapshotAt: state?.lastSnapshotAt ?? null, + recoveryState: state?.recoveryState ?? "healthy", + message: + state?.message ?? + (state?.retentionAt == null + ? null + : `Automatic removal is scheduled for ${new Date(state.retentionAt).toISOString()}. Use keep to retain this machine.`), + retentionAt: state?.retentionAt ?? null, + keep: state?.keep ?? false, + }; +} + +export function assertMachineLifecycleAdmission( + deps: Deps, + hostId: string, +): void { + const state = getMachineLifecycle(deps, hostId); + if (state === undefined) return; + if (state.recoveryState === "lost-since-last-snapshot") + throw new ApiError( + 409, + "machine_preservation_lost", + state.message ?? "Machine preservation was lost", + ); + if ( + state.leaseId !== null || + state.recoveryState === "draining" || + state.recoveryState === "saving" || + (state.maintenanceAt !== null && + state.maintenanceAt <= Date.now() && + state.observedState === "running") + ) + throw new ApiError( + 409, + "machine_maintenance", + state.message ?? + "Machine is preserving its filesystem; dispatch will wait", + ); + if (state.observedState === "unknown") + throw new ApiError( + 409, + "machine_state_unknown", + state.message ?? "Machine state is unknown; retry observation", + ); +} + +export async function maintainMachine( + deps: LoggedPendingInteractionWorkSessionDeps, + hostId: string, + save: () => Promise, +): Promise { + const leaseId = randomUUID(); + const claimed = deps.db.transaction((tx) => { + const row = tx + .select() + .from(machineLifecycles) + .where(eq(machineLifecycles.hostId, hostId)) + .get(); + if ( + row === undefined || + (row.leaseUntil !== null && row.leaseUntil > Date.now()) || + (row.retryAt !== null && row.retryAt > Date.now()) || + row.recoveryState === "lost-since-last-snapshot" + ) + return false; + tx.update(machineLifecycles) + .set({ + leaseId, + leaseUntil: Date.now() + LEASE_MS, + recoveryState: "draining", + message: + "Preserving this machine. Active turns will be interrupted and open terminals closed before the filesystem is saved.", + }) + .where(eq(machineLifecycles.hostId, hostId)) + .run(); + return true; + }); + if (!claimed) return; + const owned = and( + eq(machineLifecycles.hostId, hostId), + eq(machineLifecycles.leaseId, leaseId), + ); + const heartbeat = setInterval(() => { + deps.db + .update(machineLifecycles) + .set({ leaseUntil: Date.now() + LEASE_MS }) + .where(owned) + .run(); + }, 8_000); + try { + const state = getMachineLifecycle(deps, hostId); + const drainMs = Math.min( + 5 * 60_000, + state?.expiresAt == null + ? 5 * 60_000 + : Math.max(1_000, Math.floor((state.expiresAt - Date.now()) / 3)), + ); + 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" }, + }), + ), + ); + }, drainMs); + if (getMachineLifecycle(deps, hostId)?.leaseId !== leaseId) + throw new Error("Machine maintenance lease was replaced"); + deps.db + .update(machineLifecycles) + .set({ + recoveryState: "saving", + message: "Saving the filesystem before terminating compute.", + }) + .where(owned) + .run(); + await save(); + deps.db + .update(machineLifecycles) + .set({ + recoveryState: "saved", + message: null, + observedState: "suspended", + expiresAt: null, + maintenanceAt: null, + retryAt: null, + }) + .where(owned) + .run(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const state = getMachineLifecycle(deps, hostId); + const expired = state?.expiresAt != null && state.expiresAt <= Date.now(); + deps.db + .update(machineLifecycles) + .set({ + recoveryState: expired ? "lost-since-last-snapshot" : "recoverable", + message: `Urgent preservation failure: ${message}. Last successful save: ${state?.lastSnapshotAt == null ? "none" : new Date(state.lastSnapshotAt).toISOString()}. ${expired ? "Changes since that save may be lost." : "Old compute is retained; preservation will retry."}`, + retryAt: Date.now() + RETRY_MS, + }) + .where(owned) + .run(); + const host = getHost(deps.db, hostId); + if (state?.leaseId === leaseId && host?.phase === "suspending") + updateHost(deps.db, deps.hub, hostId, { + phase: host.retireAt === null ? "active" : "retiring", + }); + throw error; + } finally { + clearInterval(heartbeat); + deps.db + .update(machineLifecycles) + .set({ leaseId: null, leaseUntil: null }) + .where(owned) + .run(); + } +} + +export async function waitForMachineMaintenance( + deps: Deps, + hostId: string, +): Promise { + const deadline = Date.now() + 20 * 60_000; + for (;;) { + const state = getMachineLifecycle(deps, hostId); + if ( + state === undefined || + state.recoveryState === "lost-since-last-snapshot" || + state.observedState === "unknown" + ) + break; + const waiting = + state.leaseId !== null || + state.recoveryState === "draining" || + state.recoveryState === "saving" || + (state.maintenanceAt !== null && + state.maintenanceAt <= Date.now() && + state.observedState === "running"); + if (!waiting || Date.now() >= deadline) break; + await new Promise((resolve) => setTimeout(resolve, 250)); + } + assertMachineLifecycleAdmission(deps, 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..3e0c545bcf --- /dev/null +++ b/apps/server/src/services/machines/machine-services.ts @@ -0,0 +1,30 @@ +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, + dataDir: deps.config.dataDir, + machineAuth: deps.machineAuth, + serverAccess: { + resolve: (request) => serverAccess.resolve(deps, request), + release: (request) => serverAccess.release(deps, request), + }, + isConnected: (hostId) => deps.hub.hasDaemonForHost(hostId), + }); + services.set(deps.db, service); + } + return service; +} diff --git a/apps/server/src/services/machines/manual-enrollment-command.ts b/apps/server/src/services/machines/manual-enrollment-command.ts new file mode 100644 index 0000000000..d8597fd40e --- /dev/null +++ b/apps/server/src/services/machines/manual-enrollment-command.ts @@ -0,0 +1,11 @@ +import type { EnrollmentBootstrap } from "@get-bb/plugin-sdk"; + +function quote(value: string): string { + return "'" + value.replaceAll("'", "'\"'\"'") + "'"; +} + +export function manualEnrollmentCommand( + bootstrap: EnrollmentBootstrap, +): string { + return `export BB_ENROLLMENT=${quote(JSON.stringify(bootstrap))}\nif command -v bb >/dev/null 2>&1 && bb machine enroll --help >/dev/null 2>&1; then\n bb machine enroll --bootstrap-env BB_ENROLLMENT && bb machine start --host-id ${quote(bootstrap.hostId)}\nelse\n 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 - -fL --progress-meter --connect-timeout 10 --max-time 60 --retry 2 ${quote(new URL("/install.sh", bootstrap.serverUrl).href)} | sh -s -- --bootstrap-env BB_ENROLLMENT\nfi\nunset BB_ENROLLMENT`; +} 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..7779401fc5 --- /dev/null +++ b/apps/server/src/services/machines/provider-availability.ts @@ -0,0 +1,167 @@ +import { jsonValueSchema } from "@bb/domain"; +import type { SystemMachineProvider } from "@bb/server-contract"; +import type { PluginMachineProviderAvailabilityContext } from "@get-bb/plugin-sdk/machine-provider"; +import { z } from "zod"; +import type { WorkSessionDeps } from "../../types.js"; +import { decideWithinBox } from "../threads/dispatch-hooks.js"; +import { requirePublicProject } from "../lib/entity-lookup.js"; +import { + invokeMachineProvider, + machineProviderDecisionTimeoutMs, + type PluginMachineProviderRecord, +} from "../plugins/plugin-machine-provider-registry.js"; + +import { getEnvironmentProvider } from "../plugins/plugin-environment-provider-registry.js"; + +type Availability = SystemMachineProvider["availability"]; + +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(), +]); + +let availabilityCache = new WeakMap< + PluginMachineProviderRecord["provider"], + Map> +>(); +let emptyInputsCache = new WeakMap< + PluginMachineProviderRecord["provider"], + Promise +>(); + +export function invalidateMachineProviderAvailability(): void { + availabilityCache = new WeakMap(); + emptyInputsCache = new WeakMap(); +} + +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 resolveMachineProviderAvailability( + deps: WorkSessionDeps, + record: PluginMachineProviderRecord, + query: { projectId?: string }, +): Promise { + const project = + query.projectId === undefined + ? null + : requirePublicProject(deps.db, query.projectId); + if ( + record.provider.requires.gitRemote && + project !== null && + project.gitRemoteUrl === null + ) { + return { + status: "unavailable", + message: "This project has no git remote.", + }; + } + const context: PluginMachineProviderAvailabilityContext = { + project, + gitRemote: project?.gitRemoteUrl ?? null, + }; + const key = JSON.stringify(context); + let providerCache = availabilityCache.get(record.provider); + if (providerCache === undefined) { + providerCache = new Map(); + availabilityCache.set(record.provider, providerCache); + } + const cached = providerCache.get(key); + if (cached !== undefined) return cached; + const resolved = invokeAvailability(record, context); + providerCache.set(key, resolved); + return resolved; +} + +async function invokeAvailability( + record: PluginMachineProviderRecord, + context: PluginMachineProviderAvailabilityContext, +): Promise { + const availability = record.provider.availability; + if (availability === null) return { status: "available" }; + const invocation = await invokeMachineProvider( + record, + `"${record.provider.id}" machine provider availability`, + () => + decideWithinBox( + () => Promise.resolve(availability(context)), + machineProviderDecisionTimeoutMs(), + ), + ); + const failure = !invocation.ok + ? invocation.error + : invocation.value.ok + ? null + : invocation.value.error; + if (failure !== null) { + return { + status: "unavailable", + message: `Plugin "${record.pluginId}" could not determine availability: ${failure}`, + }; + } + if (!invocation.ok || !invocation.value.ok) { + return { + status: "unavailable", + message: `Plugin "${record.pluginId}" could not determine availability.`, + }; + } + const parsed = availabilitySchema.safeParse(invocation.value.value); + if (!parsed.success) { + return { + status: "unavailable", + message: `Plugin "${record.pluginId}" returned an invalid availability result.`, + }; + } + return parsed.data; +} + +export function resolveMachineProviderEnvironmentRow( + deps: Pick, + record: PluginMachineProviderRecord, + query: { projectId?: string }, +): SystemMachineProvider["environmentRow"] { + const row = record.provider.environmentRow; + if (row === null) return null; + const environment = getEnvironmentProvider(row.environmentProviderId); + if (environment === undefined) return row; + if (environment.provider.requires.projectCheckout) { + const project = + query.projectId === undefined + ? null + : requirePublicProject(deps.db, query.projectId); + if (project === null || project.gitRemoteUrl === null) return null; + } + return row; +} 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..244df7079e --- /dev/null +++ b/apps/server/src/services/machines/provider-orchestration.ts @@ -0,0 +1,1825 @@ +import { and, eq } from "drizzle-orm"; +import { hostDaemonSessions } from "@bb/db"; +import { handleHostRemoved } from "../../internal/session-owner-side-effects.js"; +import { + beginMachineRestoreSetup, + runMachineRestoreSetup, +} from "./restore-setup.js"; +import type { WorkSessionDeps } from "../../types.js"; +import { machineLifecycles } from "@bb/db"; +import { + getMachineLifecycle, + observeMachineLifecycle, + maintainMachine, +} from "./lifecycle.js"; +import type { MachineLaunchStatus } from "@bb/server-contract"; +import { serverAccess } from "./server-access.js"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { + deleteProjectSource, + settleMachineEnrollments, + getHost, + getMachineLaunch, + listEnvironments, + listMachineLaunchesByPhase, + listProjectSourcesByHost, + listProviderMachines, + listThreadIdsWithHostOfflineQueueWaits, + machineHasLiveThreads, + machineHasOpenTerminal, + machineIdleSince, + updateHost, + updateMachineLaunchAttempt, + upsertMachineLaunch, + type MachineLaunchRow, +} from "@bb/db"; +import { jsonValueSchema, type Host, type JsonValue } from "@bb/domain"; +import type { + PluginMachineProviderCreateResult, + PluginMachineProviderProgress, +} from "@get-bb/plugin-sdk/machine-provider"; +import { summarizeStandardIssues } from "@get-bb/plugin-sdk/internal/host-policy"; +import { ApiError } from "../../errors.js"; +import type { ThreadProvisioningDeps } from "../threads/thread-provisioning-environment.js"; +import { decideWithinBox } from "../threads/dispatch-hooks.js"; +import { + getMachineProvider, + invokeMachineProvider, + listMachineProviders, + machineProviderDecisionTimeoutMs, + type PluginMachineProviderRecord, +} from "../plugins/plugin-machine-provider-registry.js"; +import { requirePublicProject } from "../lib/entity-lookup.js"; +import { + requestEnvironmentRemoval, + sweepProviderEnvironment, +} from "../environments/provider-orchestration.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 TRANSIENT_RETRY_MS = 30_000; +const TRANSIENT_RETRY_LIMIT = 3; +const resourceSchema = jsonValueSchema.refine( + (value) => Buffer.byteLength(JSON.stringify(value)) <= 16_384, + "Resource exceeds 16 KiB", +); +const createResultSchema = z.discriminatedUnion("status", [ + z.object({ + status: z.literal("created"), + hostId: z.string().min(1), + resource: resourceSchema, + }), + z.object({ + status: z.literal("failed"), + failure: z.enum(["terminal", "transient"]), + allocation: z.literal("none").optional(), + message: z.string().min(1), + }), +]); +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 cancelOperations = new WeakMap>(); +const suspendOperations = new WeakMap>(); +const resumeOperations = new WeakMap>(); +const removeOperations = new WeakMap>(); +const deadlineSweepOperations = new WeakMap< + object, + Map +>(); + +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); + } +} + +function mutateLaunch( + deps: Deps, + launch: MachineLaunchRow, + phases: MachineLaunchRow["phase"][], + change: (row: MachineLaunchRow) => void, +): boolean { + const row = getMachineLaunch(deps.db, launch.key); + if ( + row === null || + row.attempt !== launch.attempt || + !phases.includes(row.phase) + ) { + return false; + } + change(row); + return updateMachineLaunchAttempt(deps.db, row); +} + +function launchReporter( + deps: Deps, + launch: MachineLaunchRow, +): PluginMachineProviderProgress { + const update = (change: (row: MachineLaunchRow) => void): void => { + mutateLaunch(deps, launch, ["creating"], change); + }; + return { + step: (text) => + update((row) => { + row.stepText = text.slice(0, 200); + }), + log: (text) => + update((row) => { + row.pendingLog = (row.pendingLog + text).slice(-16_384); + }), + }; +} + +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, { + teardownMessage: 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, + launch: MachineLaunchRow, + deps: Deps, + signal: AbortSignal, +): Promise { + const project = + launch.projectId === null + ? null + : requirePublicProject(deps.db, launch.projectId); + const invocation = await invokeMachineProvider(record, "machine create", () => + record.provider.create({ + ...(project === null + ? { project: null, gitRemote: null } + : { + project, + gitRemote: record.provider.requires.gitRemote + ? project.gitRemoteUrl + : null, + }), + inputs: launch.inputs, + key: launch.key, + attempt: launch.attempt, + checkpoint: async (resource) => { + const parsed = resourceSchema.parse(resource); + const updated = mutateLaunch( + deps, + launch, + ["creating", "cancelled", "failed"], + (row) => { + if (row.hostId === null) + throw new Error( + "Prepare enrollment before checkpointing a machine resource", + ); + row.resource = parsed; + row.cleanupResourceRemoved = false; + }, + ); + if (!updated) + throw new Error( + "Machine launch attempt no longer owns this resource", + ); + }, + report: launchReporter(deps, launch), + signal, + }), + ); + if (!invocation.ok) throw new Error(invocation.error); + const result = createResultSchema.parse(invocation.value); + const reservedHostId = + getMachineLaunch(deps.db, launch.key)?.hostId ?? launch.hostId; + if ( + result.status === "created" && + reservedHostId !== null && + result.hostId !== reservedHostId + ) { + throw new Error( + `Machine provider "${record.provider.id}" returned host "${result.hostId}" instead of reserved host "${reservedHostId}"`, + ); + } + return result; +} + +async function removeResource( + deps: Deps, + record: PluginMachineProviderRecord, + args: { hostId: string; resource: JsonValue; signal: AbortSignal }, +): Promise { + const invocation = await invokeMachineProvider(record, "machine remove", () => + record.provider.remove({ + hostId: args.hostId, + resource: args.resource, + report: lifecycleReporter(deps, args.hostId), + signal: args.signal, + }), + ); + if (!invocation.ok) throw new Error(invocation.error); + const result = removeResultSchema.parse(invocation.value); + if (result.status === "failed") throw new Error(result.message); +} + +async function runCreate( + deps: Deps, + record: PluginMachineProviderRecord, + launch: MachineLaunchRow, + signal: AbortSignal, +): Promise { + try { + const result = await invokeCreate(record, launch, deps, signal); + if (result.status === "failed") { + mutateLaunch(deps, launch, ["creating"], (row) => { + row.phase = "failed"; + row.failure = result.failure; + row.message = result.message; + row.failedAt = Date.now(); + if (result.allocation === "none" && row.resource === null) { + row.cleanupResourceRemoved = true; + row.cancelPending = true; + } + if (result.failure === "transient") row.transientFailures += 1; + }); + return; + } + const current = getMachineLaunch(deps.db, launch.key); + if ( + current === null || + current.attempt !== launch.attempt || + current.phase !== "creating" + ) { + if ( + current?.phase === "cancelled" && + current.attempt === launch.attempt + ) { + updateMachineLaunchAttempt(deps.db, { + ...current, + hostId: result.hostId, + resource: result.resource, + cleanupResourceRemoved: false, + }); + } else { + await removeResource(deps, record, { + ...result, + signal: new AbortController().signal, + }); + } + return; + } + const host = getHost(deps.db, result.hostId); + if (host === null || host.destroyedAt !== null) { + await removeResource(deps, record, { ...result, signal }); + throw new Error( + `Machine provider "${record.provider.id}" returned host "${result.hostId}" without enrolling it`, + ); + } + if ( + host.machineProviderId !== null && + host.machineProviderId !== record.provider.id + ) { + await removeResource(deps, record, { ...result, signal }); + throw new Error( + `Machine provider "${record.provider.id}" returned host "${result.hostId}", which belongs to "${host.machineProviderId}"`, + ); + } + updateHost(deps.db, deps.hub, result.hostId, { + machineProviderId: record.provider.id, + machineProviderSelection: { inputs: launch.inputs }, + phase: "active", + resource: result.resource, + retireAt: null, + suspendedAt: null, + teardownAttempt: 0, + teardownMessage: null, + teardownStatus: null, + }); + deps.hub.notifyHost(result.hostId, ["host-connected"]); + mutateLaunch(deps, launch, ["creating"], (row) => { + row.phase = "ready"; + row.hostId = result.hostId; + row.resource = result.resource; + row.message = null; + row.failure = null; + row.failedAt = null; + }); + } catch (error) { + const current = getMachineLaunch(deps.db, launch.key); + if (signal.aborted && current?.phase === "cancelled") return; + mutateLaunch(deps, launch, ["creating"], (row) => { + row.phase = "failed"; + row.failure = "terminal"; + row.message = `The "${record.provider.id}" machine provider (plugin "${record.pluginId}") failed: ${errorMessage(error)}`; + row.failedAt = Date.now(); + }); + } +} + +function startCreate( + deps: Deps, + record: PluginMachineProviderRecord, + launch: MachineLaunchRow, +): ActiveOperation { + const operation = runTrackedOperation({ + map: operations(createOperations, deps.db), + key: `${launch.key}:${launch.attempt}`, + run: (signal) => runCreate(deps, record, launch, signal), + }); + void operation.done + .then(async () => { + const current = getMachineLaunch(deps.db, launch.key); + if (current?.phase === "failed" && current.failure === "terminal") { + await cancelMachineLaunch(deps, launch.key, true); + } + }) + .catch((error: unknown) => { + deps.logger.warn( + { key: launch.key, error: errorMessage(error) }, + "Machine creation cleanup failed", + ); + }); + return operation; +} + +export async function parseMachineProviderInputs( + record: PluginMachineProviderRecord, + inputs: JsonValue | null, +): Promise { + const schema = record.provider.inputs; + if (schema === null) { + if (inputs !== null) { + throw new ApiError( + 400, + "invalid_request", + `The "${record.provider.id}" machine provider takes no inputs, but the request carried some`, + ); + } + return null; + } + if (inputs === null) { + throw new ApiError( + 400, + "invalid_request", + `The "${record.provider.id}" machine provider needs inputs, and the request carried none`, + ); + } + const invocation = await invokeMachineProvider( + record, + `"${record.provider.id}" machine provider inputs`, + async () => schema["~standard"].validate(inputs), + ); + if (!invocation.ok) { + throw new ApiError( + 502, + "machine_provider_failed", + `The "${record.provider.id}" machine provider (plugin "${record.pluginId}") failed to validate its inputs: ${invocation.error}`, + ); + } + if (invocation.value.issues !== undefined) { + throw new ApiError( + 400, + "invalid_request", + `The "${record.provider.id}" machine provider refused the inputs: ${summarizeStandardIssues(invocation.value.issues)}`, + ); + } + const parsed = jsonValueSchema.safeParse(invocation.value.value); + if (!parsed.success) { + throw new ApiError( + 502, + "machine_provider_failed", + `The "${record.provider.id}" machine provider parsed its inputs into a value that is not JSON`, + ); + } + return parsed.data; +} + +export async function prepareMachineProviderSelection( + deps: Deps, + args: { + machineProviderId: string; + projectId: string | null; + 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 project = + args.projectId === null + ? null + : requirePublicProject(deps.db, args.projectId); + if ( + record.provider.requires.gitRemote && + project !== null && + project.gitRemoteUrl === null + ) { + throw new ApiError( + 409, + "machine_provider_rejected", + `${project.name} has no git remote, so the "${record.provider.id}" machine provider has nothing to clone.`, + ); + } + 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?.({ + ...(project === null + ? { project: null, gitRemote: null } + : { + project, + gitRemote: record.provider.requires.gitRemote + ? project.gitRemoteUrl + : null, + }), + 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 } + | { action: "ready"; host: Host; log: string }; + +export function resolveThreadMachineLaunchKey( + deps: Pick, + threadId: string, +): string { + let key = threadId; + const visited = new Set(); + for (;;) { + if (visited.has(key)) + throw new Error("Machine replacement history contains a cycle"); + visited.add(key); + const launch = getMachineLaunch(deps.db, key); + if (launch?.phase !== "ready" || launch.hostId === null) return key; + const host = getHost(deps.db, launch.hostId); + if ( + host !== null && + host.destroyedAt === null && + host.removalStartedAt === null + ) + return key; + key = `${threadId}:replacement:${launch.hostId}`; + } +} + +export function askMachineLaunch( + deps: Deps, + args: { + key: string; + record: PluginMachineProviderRecord; + projectId: string | null; + inputs: JsonValue | null; + }, +): MachineLaunchDecision { + const now = Date.now(); + let row = getMachineLaunch(deps.db, args.key); + const changed = + row !== null && + (row.providerId !== args.record.provider.id || + row.projectId !== args.projectId || + JSON.stringify(row.inputs) !== JSON.stringify(args.inputs)); + if (changed) { + throw new ApiError( + 409, + "machine_launch_key_conflict", + `Machine launch key "${args.key}" is already in use`, + ); + } + if (row?.phase === "ready") { + const host = row.hostId === null ? null : getHost(deps.db, row.hostId); + if ( + host !== null && + host.destroyedAt === null && + host.removalStartedAt === null + ) { + return { + action: "ready", + host: machineHostResponse(host, deps), + log: takeLaunchLog(deps, row), + }; + } + return { + action: "reject", + message: `Machine launch key "${args.key}" belongs to a destroyed machine; use a new key to create a replacement`, + }; + } + if (row?.phase === "failed") { + if ( + row.failure === "terminal" || + row.transientFailures > TRANSIENT_RETRY_LIMIT + ) { + return { + action: "reject", + message: row.message ?? "Machine creation failed", + }; + } + const retryAt = (row.failedAt ?? now) + TRANSIENT_RETRY_MS; + if (now < retryAt) { + return { + action: "wait", + reason: `${row.message ?? "Machine creation failed"}; retrying`, + sendAt: retryAt, + log: takeLaunchLog(deps, row), + }; + } + } + if (row === null || row.phase === "failed") { + const attempt = (row?.attempt ?? 0) + 1; + row = { + key: args.key, + providerId: args.record.provider.id, + projectId: args.projectId, + inputs: args.inputs, + attempt, + phase: "creating", + startedAt: now, + failedAt: null, + failure: null, + message: null, + transientFailures: row?.transientFailures ?? 0, + hostId: row?.hostId ?? null, + resource: row?.resource ?? null, + stepText: `Creating ${args.record.provider.displayName}…`, + pendingLog: "", + cancelPending: false, + cleanupResourceRemoved: false, + cleanupRetryAt: null, + }; + upsertMachineLaunch(deps.db, row); + startCreate(deps, args.record, row); + } else if (row.phase === "creating") { + startCreate(deps, args.record, row); + } else if (row.phase === "cancelled") { + return { action: "reject", message: "Machine creation was cancelled" }; + } + return { + action: "wait", + reason: row.stepText, + sendAt: now + 1_000, + log: takeLaunchLog(deps, row), + }; +} + +function takeLaunchLog(deps: Deps, row: MachineLaunchRow): string { + const log = row.pendingLog; + if (log.length > 0) { + updateMachineLaunchAttempt(deps.db, { ...row, pendingLog: "" }); + } + return log; +} + +function machineHostResponse( + row: NonNullable>, + deps: Deps, +): Host { + return { + id: row.id, + name: row.name, + status: deps.hub.hasDaemonForHost(row.id) ? "connected" : "disconnected", + machineProviderId: row.machineProviderId, + machineProviderSelection: row.machineProviderSelection, + lifecycle: { + phase: row.phase === "suspending" ? "active" : row.phase, + suspendedAt: row.suspendedAt, + retireAt: row.retireAt, + progress: row.teardownStatus === null ? row.teardownMessage : null, + teardown: + row.teardownStatus === null + ? null + : { + status: row.teardownStatus, + attempt: row.teardownAttempt, + ...(row.teardownMessage === null + ? {} + : { message: row.teardownMessage }), + }, + }, + maxPermissionMode: row.maxPermissionMode, + lastSeenAt: row.lastSeenAt, + lastRejectedProtocolVersion: row.lastRejectedProtocolVersion, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} + +export async function cancelMachineLaunch( + deps: Deps, + key: string, + preserveFailure = false, + forceRetry = false, +): Promise { + let row = getMachineLaunch(deps.db, key); + if (row === null || row.phase === "ready") return; + if ( + row.phase !== "cancelled" && + !( + row.phase === "failed" && + row.cleanupResourceRemoved && + !row.cancelPending + ) + ) { + row = { + ...row, + phase: preserveFailure ? "failed" : "cancelled", + cancelPending: true, + }; + updateMachineLaunchAttempt(deps.db, row); + } + if (row.cleanupResourceRemoved && !row.cancelPending) return; + if (row.hostId !== null) { + settleMachineEnrollments(deps.db, row.hostId); + await deps.machineAuth.revokeHostEnrollKeys({ hostId: row.hostId }); + } + const create = operations(createOperations, deps.db).get( + `${row.key}:${row.attempt}`, + ); + if (create !== undefined) { + create.controller.abort(); + await create.done; + } + row = getMachineLaunch(deps.db, key); + if ( + row === null || + !row.cancelPending || + (!forceRetry && + row.cleanupRetryAt !== null && + row.cleanupRetryAt > Date.now()) + ) + return; + const record = getMachineProvider(row.providerId); + if (record === undefined) return; + const operation = runTrackedOperation({ + map: operations(cancelOperations, deps.db), + key, + run: async (signal) => { + let current = getMachineLaunch(deps.db, key); + if (current === null || !current.cancelPending) return; + let allocationError: Error | null = null; + try { + if (!current.cleanupResourceRemoved) { + if (current.resource === null) { + const launch = current; + const invocation = await invokeMachineProvider( + record, + "machine cleanup reconciliation", + () => + record.provider.experimental_reconcileCleanup({ + key: launch.key, + report: launchReporter(deps, launch), + 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 { + if (current.hostId === null) + throw new Error("Machine cleanup has no reserved host"); + await removeResource(deps, record, { + hostId: current.hostId, + resource: current.resource, + signal, + }); + } + current = { ...current, cleanupResourceRemoved: true }; + updateMachineLaunchAttempt(deps.db, current); + } + } catch (error) { + allocationError = new Error(errorMessage(error)); + } + const removedHostId = current.hostId; + if (removedHostId !== null) { + await serverAccess.release(deps, { key, hostId: removedHostId }); + deleteMachineProjectSources(deps, removedHostId); + await deps.machineAuth.revokeHostAuthKeys({ hostId: removedHostId }); + expireMachineSessions(deps, removedHostId); + const host = getHost(deps.db, removedHostId); + if (host !== null && host.destroyedAt === null) { + updateHost(deps.db, deps.hub, removedHostId, { + destroyedAt: Date.now(), + phase: "destroyed", + resource: null, + retireAt: null, + suspendedAt: null, + teardownStatus: "removed", + teardownMessage: null, + }); + deps.hub.notifyHost(removedHostId, ["host-disconnected"]); + } + } + + if (allocationError !== null) throw allocationError; + updateMachineLaunchAttempt(deps.db, { + ...current, + cancelPending: false, + cleanupRetryAt: null, + resource: null, + }); + }, + }); + try { + await operation.done; + } catch (error) { + const current = getMachineLaunch(deps.db, key); + if (current !== null && current.cancelPending) { + updateMachineLaunchAttempt(deps.db, { + ...current, + cleanupRetryAt: + current.resource === null && + !current.cleanupResourceRemoved && + current.hostId === null && + Date.now() - current.startedAt >= 30 * 60_000 + ? Number.MAX_SAFE_INTEGER + : Date.now() + record.provider.policy.removeRetryMs, + }); + } + throw error; + } +} + +export function machineLaunchStatus( + deps: Deps, + key: string, +): MachineLaunchStatus { + const row = getMachineLaunch(deps.db, key); + if (row === null) + throw new ApiError( + 404, + "machine_launch_not_found", + "Machine launch not found", + ); + return { + id: row.key, + phase: row.phase, + hostId: row.hostId, + step: row.stepText, + log: row.pendingLog, + message: row.message, + cancelPending: row.cancelPending, + terminal: + row.phase === "ready" || + row.phase === "cancelled" || + (row.phase === "failed" && + (row.failure === "terminal" || + row.transientFailures > TRANSIENT_RETRY_LIMIT)), + }; +} + +export async function submitMachine( + deps: Deps, + args: { + key?: string; + machineProviderId: string; + projectId: string | null; + inputs: JsonValue | null; + }, +): Promise { + const key = args.key ?? `machine-${randomUUID()}`; + const prepared = await prepareMachineProviderSelection(deps, args); + const decision = askMachineLaunch(deps, { + key, + record: prepared.record, + projectId: args.projectId, + inputs: prepared.inputs, + }); + if ( + decision.action === "reject" && + getMachineLaunch(deps.db, key)?.phase === "ready" + ) + throw new ApiError(409, "machine_provider_rejected", decision.message); + return machineLaunchStatus(deps, key); +} + +export async function createMachine( + deps: Deps, + args: Parameters[1] & { signal?: AbortSignal }, +): Promise { + const launch = await submitMachine(deps, args); + for (;;) { + args.signal?.throwIfAborted(); + const status = machineLaunchStatus(deps, launch.id); + if (status.phase === "ready" && status.hostId !== null) { + const host = getHost(deps.db, status.hostId); + if (host !== null) return machineHostResponse(host, deps); + } + if (status.terminal) { + throw new ApiError( + 409, + "machine_provider_rejected", + status.message ?? "Machine creation cancelled", + ); + } + await new Promise((resolve) => setTimeout(resolve, 250)); + } +} + +function lifecycleOwns( + current: ReturnType, + providerId: string, + operationId: string, + phase: + | NonNullable>["phase"] + | NonNullable>["phase"][], +): current is NonNullable> { + return ( + current !== null && + current.destroyedAt === null && + current.removalStartedAt === 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): Promise { + 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) { + await suspending.done; + return; + } + const row = getHost(deps.db, hostId); + if ( + row === null || + row.machineProviderId === null || + (row.phase !== "active" && + !(row.phase === "retiring" && 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()}`; + if ( + record.provider.experimental_observe === undefined && + (machineIdleSince(deps.db, hostId) === null || + machineHasOpenTerminal(deps.db, hostId)) + ) { + throw new ApiError( + 409, + "machine_busy", + "Wait for live threads to become idle and close terminals before sleeping.", + ); + } + const suspend = record.provider.suspend; + const resource = row.resource; + const retiring = row.phase === "retiring"; + const maintenanceLease = getMachineLifecycle(deps, hostId)?.leaseId ?? null; + const operation = runTrackedOperation({ + map: operations(suspendOperations, deps.db), + key: hostId, + run: async (signal) => { + updateHost(deps.db, deps.hub, hostId, { + phase: "suspending", + machineOperationId: operationId, + teardownMessage: null, + teardownStatus: null, + }); + const invocation = await invokeMachineProvider( + record, + "machine suspend", + () => + suspend({ + hostId, + resource, + report: lifecycleReporter(deps, hostId), + signal, + checkpoint: (checkpoint, snapshotAt) => { + const parsed = resourceSchema.parse(checkpoint); + if ( + maintenanceLease !== null && + getMachineLifecycle(deps, hostId)?.leaseId !== maintenanceLease + ) + throw new Error( + "Machine maintenance lease was replaced before checkpoint", + ); + const current = getHost(deps.db, hostId); + if ( + !lifecycleOwns(current, record.provider.id, operationId, [ + "suspending", + "retiring", + ]) + ) { + throw new Error(`Machine "${hostId}" is no longer active`); + } + updateHost(deps.db, deps.hub, hostId, { + resource: parsed, + }); + if (snapshotAt !== undefined) { + const savedAt = z + .number() + .finite() + .nonnegative() + .max(Date.now()) + .parse(snapshotAt); + deps.db + .update(machineLifecycles) + .set({ lastSnapshotAt: savedAt }) + .where(eq(machineLifecycles.hostId, hostId)) + .run(); + deps.logger.info( + { hostId, operationId, savedAt }, + "Machine filesystem checkpoint persisted", + ); + } + }, + }), + ); + 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", + "retiring", + ]) + ) { + return; + } + updateHost(deps.db, deps.hub, hostId, { + phase: + retiring || current.phase === "retiring" ? "retiring" : "suspended", + resource: result.resource, + suspendedAt: Date.now(), + teardownMessage: null, + teardownStatus: null, + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + }, + }); + 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; +} + +export async function requestMachineSuspension( + deps: Deps, + hostId: string, +): Promise { + const row = requireSuspendableMachine(deps, hostId); + await observeMachineLifecycle(deps, hostId); + if (row.phase !== "active" && row.phase !== "suspending") { + throw new ApiError( + 409, + "machine_not_active", + "Only an active machine can be suspended", + ); + } + if (getMachineLifecycle(deps, hostId) !== undefined) + await maintainMachine(deps, hostId, () => suspendMachine(deps, hostId)); + else await suspendMachine(deps, hostId); +} + +export async function requestMachineResume( + deps: Deps, + hostId: string, +): Promise { + const row = requireSuspendableMachine(deps, hostId); + const lifecycle = getMachineLifecycle(deps, hostId); + if (lifecycle?.recoveryState === "lost-since-last-snapshot") { + updateHost(deps.db, deps.hub, hostId, { phase: "suspended" }); + deps.db + .update(machineLifecycles) + .set({ + leaseId: null, + leaseUntil: null, + maintenanceAt: null, + message: + "Explicitly recovering the last successful snapshot; newer changes may have been lost.", + }) + .where(eq(machineLifecycles.hostId, hostId)) + .run(); + await resumeMachine(deps, hostId); + return; + } + if ( + row.phase !== "active" && + row.phase !== "suspended" && + row.phase !== "suspending" + ) { + throw new ApiError( + 409, + "machine_not_suspended", + "Only an active or suspended machine can be resumed", + ); + } + await resumeMachine(deps, hostId); +} + +export async function resumeMachine( + deps: WorkSessionDeps, + hostId: string, +): Promise { + const removing = operations(removeOperations, deps.db).get(hostId); + if (removing !== undefined) { + await removing.done.catch(() => {}); + return; + } + const suspending = operations(suspendOperations, deps.db).get(hostId); + if (suspending !== undefined) { + await suspending.done.catch(() => {}); + } + await resumeMachineWithIntent(deps, hostId, false); +} + +async function resumeMachineWithIntent( + deps: WorkSessionDeps, + hostId: string, + preserveRetirement: boolean, +): Promise { + let row = getHost(deps.db, hostId); + if ( + row === null || + row.machineProviderId === null || + row.removalStartedAt !== null + ) + return; + const hasLiveThreads = machineHasLiveThreads(deps.db, hostId); + if (row.phase === "retiring" && row.suspendedAt === null && hasLiveThreads) { + updateHost(deps.db, deps.hub, hostId, { + phase: "active", + retireAt: null, + teardownMessage: null, + teardownStatus: null, + }); + return; + } + if ( + row.phase !== "suspended" && + row.phase !== "suspending" && + !( + row.phase === "retiring" && + row.suspendedAt !== null && + (hasLiveThreads || preserveRetirement) + ) + ) { + return; + } + const machineProviderId = row.machineProviderId; + const record = getMachineProvider(machineProviderId); + if (record === undefined || record.provider.resume === null) { + throw new ApiError( + 409, + "machine_provider_unavailable", + `Machine provider "${machineProviderId}" is not installed`, + ); + } + if (row.resource === null) { + throw new Error(`Machine "${hostId}" has no provider resource`); + } + const operationId = `${record.pluginId}:${randomUUID()}`; + const phase = row.phase; + const 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, + }); + 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, phase) + ) { + 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, phase)) { + return; + } + const keepRetiring = + current.phase === "retiring" && !machineHasLiveThreads(deps.db, hostId); + beginMachineRestoreSetup(deps, hostId, operationId); + updateHost(deps.db, deps.hub, hostId, { + phase: keepRetiring ? "retiring" : "active", + resource: result.resource, + suspendedAt: null, + retireAt: keepRetiring ? current.retireAt : null, + teardownMessage: null, + teardownStatus: null, + }); + deps.hub.notifyHost(hostId, ["host-connected"]); + }, + }); + try { + await operation.done; + if (getMachineLifecycle(deps, hostId) !== undefined) { + deps.db + .update(machineLifecycles) + .set({ + recoveryState: "healthy", + observedState: "running", + retryAt: null, + }) + .where(eq(machineLifecycles.hostId, hostId)) + .run(); + await observeMachineLifecycle(deps, hostId); + await runMachineRestoreSetup(deps, hostId); + } + } catch (error) { + deps.db + .update(machineLifecycles) + .set({ + recoveryState: "recoverable", + message: `Restore failed: ${errorMessage(error)}. The last successful save remains the recovery point.`, + }) + .where(eq(machineLifecycles.hostId, hostId)) + .run(); + throw error; + } +} + +async function resumeRetiringMachine( + 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 (machineHasLiveThreads(deps.db, hostId)) return false; + updateHost(deps.db, deps.hub, hostId, { + phase: "retiring", + machineOperationId: + row.phase === "suspending" ? row.machineOperationId : null, + retireAt: Date.now(), + teardownStatus: null, + teardownMessage: null, + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + return true; +} + +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 !== "retiring" || + 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, { retireAt: 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 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.removalStartedAt === null && machineHasLiveThreads(deps.db, hostId)) + ) { + return; + } + const record = getMachineProvider(row.machineProviderId); + if (record === undefined) return; + if (row.resource === null) { + updateHost(deps.db, deps.hub, hostId, { + teardownStatus: "failed", + teardownMessage: `Machine "${hostId}" has no provider resource`, + retireAt: Date.now() + record.provider.policy.removeRetryMs, + }); + return; + } + const resource = row.resource; + const operationId = `${record.pluginId}:${randomUUID()}`; + const attempt = row.teardownAttempt + 1; + updateHost(deps.db, deps.hub, hostId, { + removalStartedAt: row.removalStartedAt ?? Date.now(), + machineOperationId: operationId, + teardownAttempt: attempt, + teardownStatus: "running", + teardownMessage: null, + }); + const operation = runTrackedOperation({ + map: operations(removeOperations, deps.db), + key: hostId, + run: async (signal) => { + try { + await removeResource(deps, record, { + hostId, + resource, + signal, + }); + const current = getHost(deps.db, hostId); + if ( + current?.machineOperationId !== operationId || + current.machineProviderId !== record.provider.id || + current.phase !== "retiring" || + getMachineProvider(record.provider.id)?.pluginId !== record.pluginId + ) + return; + settleMachineEnrollments(deps.db, 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 ( + latest?.machineOperationId !== operationId || + latest.phase !== "retiring" || + getMachineProvider(record.provider.id)?.pluginId !== record.pluginId + ) + return; + updateHost(deps.db, deps.hub, hostId, { + destroyedAt: Date.now(), + phase: "destroyed", + resource: null, + retireAt: null, + suspendedAt: null, + teardownStatus: "removed", + teardownMessage: null, + }); + deps.hub.notifyHost(hostId, ["host-disconnected"]); + } catch (error) { + const current = getHost(deps.db, hostId); + if ( + current?.machineOperationId !== operationId || + current.machineProviderId !== record.provider.id || + current.phase !== "retiring" || + getMachineProvider(record.provider.id)?.pluginId !== record.pluginId + ) + return; + updateHost(deps.db, deps.hub, hostId, { + teardownStatus: "failed", + teardownMessage: errorMessage(error), + retireAt: Date.now() + record.provider.policy.removeRetryMs, + }); + } + }, + }); + await operation.done; +} + +export async function sweepProviderMachine( + deps: Deps, + hostId: string, +): Promise { + 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 ( + record.provider.experimental_observe !== undefined && + row.removalStartedAt === null && + !( + row.phase === "retiring" && + row.retireAt !== null && + row.retireAt <= Date.now() + ) + ) { + if ( + operations(suspendOperations, deps.db).has(hostId) || + operations(resumeOperations, deps.db).has(hostId) + ) + return; + await observeMachineLifecycle(deps, hostId); + const lifecycle = getMachineLifecycle(deps, hostId); + if ( + lifecycle === undefined || + lifecycle.recoveryState === "lost-since-last-snapshot" + ) + return; + if (lifecycle.leaseUntil !== null && lifecycle.leaseUntil > Date.now()) + return; + row = getHost(deps.db, hostId); + if (row === null) return; + const hasQueuedWake = + listThreadIdsWithHostOfflineQueueWaits(deps.db, hostId).length > 0; + if (row.suspendedAt !== null && hasQueuedWake) { + await resumeMachine(deps, hostId); + return; + } + const idleSince = + machineIdleSince(deps.db, hostId) ?? + (!machineHasLiveThreads(deps.db, hostId) ? lifecycle.unusedSince : null); + const due = + lifecycle.maintenanceAt !== null && lifecycle.maintenanceAt <= Date.now(); + const idle = + !hasQueuedWake && + lifecycle.idleSuspendMs !== null && + idleSince !== null && + Date.now() >= idleSince + lifecycle.idleSuspendMs && + !machineHasOpenTerminal(deps.db, hostId); + if ( + row.suspendedAt === null && + (due || idle || row.phase === "suspending") + ) { + if (row.phase === "suspending") + updateHost(deps.db, deps.hub, hostId, { + phase: row.retireAt === null ? "active" : "retiring", + }); + await maintainMachine(deps, hostId, () => suspendMachine(deps, hostId)); + return; + } + if (lifecycle.retentionAt === null || lifecycle.retentionAt > Date.now()) + return; + updateHost(deps.db, deps.hub, hostId, { + phase: "retiring", + retireAt: lifecycle.retentionAt, + }); + row = getHost(deps.db, hostId); + if (row === null) return; + } + if (row.phase === "suspending") { + const suspending = operations(suspendOperations, deps.db).get(hostId); + if (suspending !== undefined) { + await suspending.done; + return; + } + await resumeMachine(deps, hostId); + return; + } + const now = Date.now(); + if (row.removalStartedAt !== null) { + if ( + !operations(removeOperations, deps.db).has(hostId) && + (row.retireAt === null || row.retireAt <= now) + ) { + await removeMachine(deps, hostId); + } + return; + } + if (operations(resumeOperations, deps.db).has(hostId)) return; + const hasLiveThreads = machineHasLiveThreads(deps.db, hostId); + if ( + row.phase === "retiring" && + row.removalStartedAt === null && + hasLiveThreads + ) { + updateHost(deps.db, deps.hub, hostId, { + phase: row.suspendedAt === null ? "active" : "suspended", + retireAt: null, + teardownMessage: null, + teardownStatus: null, + }); + row = getHost(deps.db, hostId); + if (row === null) return; + } + if ( + !hasLiveThreads && + record.provider.policy.retire.after === "last-thread" + ) { + const retireAt = + row.retireAt ?? now + record.provider.policy.retire.graceMs; + if (row.phase !== "retiring" || row.retireAt !== retireAt) { + updateHost(deps.db, deps.hub, hostId, { + phase: "retiring", + retireAt, + }); + row = getHost(deps.db, hostId); + if (row === null) return; + } + } + const idleSuspendMs = + record.provider.experimental_idleSuspendMs !== null && row.resource !== null + ? z + .number() + .int() + .nonnegative() + .nullable() + .parse( + await record.provider.experimental_idleSuspendMs({ + hostId, + resource: row.resource, + }), + ) + : record.provider.policy.idleSuspendMs; + const idleSince = + row.phase === "active" ? machineIdleSince(deps.db, hostId) : null; + if ( + row.phase === "active" && + idleSuspendMs !== null && + record.provider.suspend !== null && + !machineHasOpenTerminal(deps.db, hostId) + ) { + if (idleSince !== null && now >= idleSince + idleSuspendMs) { + await suspendMachine(deps, hostId); + return; + } + } + if (row.phase !== "retiring" || row.retireAt === null || row.retireAt > 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 !== "retiring" || + row.retireAt === null || + row.retireAt > Date.now() + ) { + return; + } + if (row.removalStartedAt === null && machineHasLiveThreads(deps.db, hostId)) { + updateHost(deps.db, deps.hub, hostId, { + phase: row.suspendedAt === null ? "active" : "suspended", + retireAt: null, + teardownMessage: null, + teardownStatus: null, + }); + return; + } + const environments = listEnvironments(deps.db, { hostId }).filter( + (environment) => + environment.status !== "destroyed" || + environment.teardownStatus !== "removed", + ); + if ( + environments.some((environment) => environment.providerOwnsPath) && + row.suspendedAt !== null + ) { + await resumeRetiringMachine(deps, hostId); + row = getHost(deps.db, hostId); + if (row === null || row.phase !== "retiring") 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.retireAt !== null && + row.retireAt > now + ) { + return; + } + await removeMachine(deps, hostId); +} + +export async function sweepMachineLifecycles( + deps: Deps, + options?: { backgroundDeadlines: true }, +): Promise { + for (const launch of listMachineLaunchesByPhase(deps.db, "creating")) { + const record = getMachineProvider(launch.providerId); + if (record !== undefined) startCreate(deps, record, launch); + } + const pending: Promise[] = []; + for (const launch of listMachineLaunchesByPhase(deps.db, "failed")) { + if ( + launch.failure === "transient" && + launch.transientFailures <= TRANSIENT_RETRY_LIMIT && + !launch.cancelPending + ) { + const record = getMachineProvider(launch.providerId); + if (record !== undefined) + askMachineLaunch(deps, { + key: launch.key, + record, + projectId: launch.projectId, + inputs: launch.inputs, + }); + continue; + } + if ( + (launch.failure === "terminal" || + launch.transientFailures > TRANSIENT_RETRY_LIMIT) && + !launch.cleanupResourceRemoved + ) { + pending.push( + cancelMachineLaunch(deps, launch.key, true).catch((error: unknown) => { + deps.logger.warn( + { key: launch.key, error: errorMessage(error) }, + "Failed machine launch cleanup will retry", + ); + }), + ); + } else if (launch.cancelPending) { + pending.push( + cancelMachineLaunch(deps, launch.key, true).catch((error: unknown) => { + deps.logger.warn( + { key: launch.key, error: errorMessage(error) }, + "Failed machine access cleanup will retry", + ); + }), + ); + } + } + for (const launch of listMachineLaunchesByPhase(deps.db, "cancelled")) { + if (launch.cancelPending) { + pending.push( + cancelMachineLaunch(deps, launch.key).catch((error: unknown) => { + deps.logger.warn( + { key: launch.key, error: errorMessage(error) }, + "Machine launch cancellation will retry", + ); + }), + ); + } + } + for (const record of listMachineProviders()) { + for (const machine of listProviderMachines(deps.db, record.provider.id)) { + const sweeping = + record.provider.experimental_observe === undefined + ? sweepProviderMachine(deps, machine.id) + : runTrackedOperation({ + map: operations(deadlineSweepOperations, 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", + teardownMessage: errorMessage(error), + ...(current.phase === "retiring" + ? { + retireAt: Date.now() + record.provider.policy.removeRetryMs, + } + : {}), + }); + } + deps.logger.warn( + { hostId: machine.id, error: errorMessage(error) }, + "Machine lifecycle sweep will retry", + ); + }); + if ( + record.provider.experimental_observe === undefined || + options?.backgroundDeadlines !== true + ) + pending.push(settled); + } + } + await Promise.all(pending); +} + +export async function getMachineProviderDetails( + deps: Deps, + hostId: string, + signal: AbortSignal, +) { + const row = getHost(deps.db, hostId); + if (row === null || row.destroyedAt !== null) + throw new ApiError(404, "host_not_found", "Host not found"); + const provider = + row.machineProviderId === null + ? null + : getMachineProvider(row.machineProviderId)?.provider; + if (!provider?.experimental_details || row.resource === null) return null; + return z + .object({ summary: z.string().max(2000), values: jsonValueSchema }) + .strict() + .parse( + await provider.experimental_details({ + hostId, + resource: row.resource, + signal, + }), + ); +} diff --git a/apps/server/src/services/machines/readiness.ts b/apps/server/src/services/machines/readiness.ts new file mode 100644 index 0000000000..504a3fec37 --- /dev/null +++ b/apps/server/src/services/machines/readiness.ts @@ -0,0 +1,227 @@ +import type { HostDaemonContributedEnvEntry } from "@bb/host-daemon-contract"; +import { + resolveHostEnvironment, + mergeHostAndProviderEnvironment, +} from "../hosts/host-environment.js"; +import { runMachineRestoreSetup } from "./restore-setup.js"; +import { and, eq } from "drizzle-orm"; +import { + getHost, + getProjectSourceByHost, + environments, + environmentSetupOutcomes, + projectSourceOwnsPath, +} from "@bb/db"; +import type { experimental_HostReadinessResponse } from "@bb/server-contract"; +import { z } from "zod"; +import type { WorkSessionDeps } from "../../types.js"; +import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; +import { resolveBridgeLaunchForProviderId } from "../system/provider-bridge-launch.js"; +import { ensureProviderInstallation } from "../system/provider-installations.js"; +import { + resolvePluginProviderEnv, + resolvePluginProviderEnvHealth, +} from "../plugins/plugin-agent-contributions.js"; +import { + environmentSetupInputHash, + reconcileLegacyEnvironmentSetupOutcome, +} from "../environments/setup-outcomes.js"; + +const probeSchema = z + .object({ + serverPath: z.string().startsWith("/").max(4096), + headers: z.record(z.string(), z.string()), + }) + .strict(); +export async function ensureHostReady( + deps: WorkSessionDeps, + args: { + hostId: string; + providerId: string; + projectId: string; + threadId: string | null; + path: string | null; + contributedEnv?: HostDaemonContributedEnvEntry[]; + }, +): Promise { + let stage: "cli" | "auth" | "workspace" = "cli"; + const blocked = ( + code: string, + message: string, + retryable = true, + ): experimental_HostReadinessResponse => ({ + status: "blocked", + code, + stage, + message, + retryable, + }); + try { + const host = getHost(deps.db, args.hostId); + if (!host || host.destroyedAt) + return blocked("host_missing", "Machine is unavailable", false); + const cli = await ensureProviderInstallation(deps, args); + if (!cli.ready) return blocked("setup_required", cli.message, false); + stage = "auth"; + const routed = await resolvePluginProviderEnvHealth({ + providerId: args.providerId, + hostId: args.hostId, + threadId: args.threadId, + }); + if (routed) { + if (args.threadId !== null) { + const entries = await resolvePluginProviderEnv({ + providerId: args.providerId, + context: { + threadId: args.threadId, + projectId: args.projectId, + hostId: args.hostId, + }, + }); + if (entries.length === 0) + return blocked( + "credential_route_unavailable", + "The selected thread has no active credential route", + ); + } + if (!routed.experimental_probe) + return blocked( + "auth_probe_unavailable", + "Credential routing does not provide a machine reachability check", + false, + ); + const probe = probeSchema.parse(routed.experimental_probe); + const result = await callHostRetryableOnlineRpc(deps, { + hostId: args.hostId, + timeoutMs: 20000, + command: { type: "host.readiness.probe", ...probe }, + }); + if (!result.reachable) + return blocked( + "credential_route_unreachable", + "The machine cannot authenticate to its credential proxy", + ); + } else { + const bridgeLaunch = resolveBridgeLaunchForProviderId( + deps, + args.providerId, + ); + if (!bridgeLaunch) + return blocked( + "auth_unavailable", + "Provider authentication cannot be checked", + false, + ); + const contributedEnv = + args.contributedEnv ?? + mergeHostAndProviderEnvironment( + await resolveHostEnvironment(deps, args), + args.threadId === null + ? [] + : await resolvePluginProviderEnv({ + providerId: args.providerId, + context: { + threadId: args.threadId, + projectId: args.projectId, + hostId: args.hostId, + }, + }), + ); + const result = await callHostRetryableOnlineRpc(deps, { + hostId: args.hostId, + timeoutMs: 60000, + command: { + type: "provider.health", + contributedEnv, + providerId: args.providerId, + bridgeLaunch, + }, + }); + if (!result.supported || result.health.status !== "ready") + return blocked( + "credentials_required", + "Configure a usable credential route or authenticate this provider on the machine", + false, + ); + } + stage = "workspace"; + await runMachineRestoreSetup(deps, args.hostId); + const path = + args.path ?? + getProjectSourceByHost(deps.db, args.projectId, args.hostId)?.path; + if (!path) + return blocked( + "checkout_required", + "Prepare this project's checkout on the machine first", + false, + ); + const environment = deps.db + .select({ ownsPath: environments.providerOwnsPath }) + .from(environments) + .where( + and( + eq(environments.projectId, args.projectId), + eq(environments.hostId, args.hostId), + eq(environments.path, path), + eq(environments.status, "ready"), + ), + ) + .limit(1) + .get(); + const ownsPath = + environment?.ownsPath ?? + projectSourceOwnsPath(deps.db, args.projectId, args.hostId, path); + if (ownsPath) { + await reconcileLegacyEnvironmentSetupOutcome(deps, { + hostId: args.hostId, + path, + }); + const outcome = deps.db + .select() + .from(environmentSetupOutcomes) + .where( + and( + eq(environmentSetupOutcomes.hostId, args.hostId), + eq(environmentSetupOutcomes.path, path), + ), + ) + .get(); + if (!outcome || outcome.state === "running") + return blocked( + "setup_required", + "The core environment setup hook has not completed for this checkout", + ); + if (outcome.state === "failed") + return blocked( + "setup_failed", + "The core environment setup hook failed or changed its tracked inputs; review the environment launch", + ); + const facts = await callHostRetryableOnlineRpc(deps, { + hostId: args.hostId, + timeoutMs: 60000, + command: { type: "workspace.readiness.inspect", path }, + }); + if (environmentSetupInputHash(facts) !== outcome.inputHash) + return blocked( + "dirty" in facts && facts.dirty.length + ? "dirty_checkout" + : "setup_stale", + "Checkout commit, lockfiles, setup hook or ABI changed since core setup succeeded; review the checkout and recreate its owned environment", + false, + ); + } + return { + status: "ready", + checks: [ + { kind: "cli", status: "passed" }, + { kind: "auth", status: "passed" }, + { kind: "workspace", status: "passed" }, + ], + }; + } catch { + return blocked( + `${stage}_unavailable`, + `Machine ${stage} readiness could not be completed; check the machine connection and configuration`, + ); + } +} diff --git a/apps/server/src/services/machines/restore-setup.ts b/apps/server/src/services/machines/restore-setup.ts new file mode 100644 index 0000000000..2501c96710 --- /dev/null +++ b/apps/server/src/services/machines/restore-setup.ts @@ -0,0 +1,143 @@ +import { and, eq, isNotNull } from "drizzle-orm"; +import { + environmentHookOperations, + environments, + machineLifecycles, +} from "@bb/db"; +import type { WorkSessionDeps } from "../../types.js"; +import { runEnvironmentHook } from "../environments/environment-hooks.js"; + +const running = new WeakMap>>(); + +export function beginMachineRestoreSetup( + deps: Pick, + hostId: string, + operationId: string, +): void { + deps.db.transaction((tx) => { + const checkouts = tx + .select({ id: environments.id, path: environments.path }) + .from(environments) + .where( + and( + eq(environments.hostId, hostId), + eq(environments.providerOwnsPath, true), + eq(environments.status, "ready"), + isNotNull(environments.path), + ), + ) + .all() + .flatMap((row) => + row.path === null ? [] : [{ id: row.id, path: row.path }], + ); + tx.update(machineLifecycles) + .set({ restoreOperationId: operationId, restoreCheckouts: checkouts }) + .where(eq(machineLifecycles.hostId, hostId)) + .run(); + }); +} + +export async function runMachineRestoreSetup( + deps: WorkSessionDeps, + hostId: string, +): Promise { + const restore = deps.db + .select({ + operationId: machineLifecycles.restoreOperationId, + checkouts: machineLifecycles.restoreCheckouts, + }) + .from(machineLifecycles) + .where(eq(machineLifecycles.hostId, hostId)) + .get(); + if (restore?.operationId == null) return; + const operationId = restore.operationId; + let pending = running.get(deps.db); + if (pending === undefined) { + pending = new Map(); + running.set(deps.db, pending); + } + const key = `${hostId}:${operationId}`; + const existing = pending.get(key); + if (existing !== undefined) return existing; + const operation = (async () => { + if (restore.checkouts === null) + beginMachineRestoreSetup(deps, hostId, operationId); + const checkouts = + restore.checkouts ?? + deps.db + .select({ checkouts: machineLifecycles.restoreCheckouts }) + .from(machineLifecycles) + .where( + and( + eq(machineLifecycles.hostId, hostId), + eq(machineLifecycles.restoreOperationId, operationId), + ), + ) + .get()?.checkouts ?? + []; + let settled = true; + for (const checkout of checkouts) { + const exists = deps.db + .select({ id: environments.id }) + .from(environments) + .where( + and( + eq(environments.id, checkout.id), + eq(environments.hostId, hostId), + eq(environments.path, checkout.path), + eq(environments.status, "ready"), + ), + ) + .get(); + if (exists === undefined) continue; + const identity = { hostId, environmentId: checkout.id }; + try { + await runEnvironmentHook(deps, { + id: `restore:${operationId}:${checkout.id}`, + hostId, + path: checkout.path, + kind: "setup", + resumeOnly: false, + signal: AbortSignal.timeout(15 * 60_000), + report: { + step: (message) => deps.logger.info(identity, message), + log: (message) => deps.logger.info(identity, message), + }, + }); + } catch (error) { + const hook = deps.db + .select({ finishedAt: environmentHookOperations.finishedAt }) + .from(environmentHookOperations) + .where( + eq( + environmentHookOperations.id, + `restore:${operationId}:${checkout.id}`, + ), + ) + .get(); + if (hook?.finishedAt == null) settled = false; + deps.logger.warn( + { ...identity, error }, + "Restored checkout setup failed; readiness is blocked", + ); + } + } + if (settled) + deps.db + .update(machineLifecycles) + .set({ restoreOperationId: null, restoreCheckouts: null }) + .where( + and( + eq(machineLifecycles.hostId, hostId), + eq(machineLifecycles.restoreOperationId, operationId), + ), + ) + .run(); + })(); + pending.set(key, operation); + try { + await operation; + } finally { + pending.delete(key); + } +} 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..7df1107991 --- /dev/null +++ b/apps/server/src/services/machines/server-access.ts @@ -0,0 +1,256 @@ +import { getAppSettings, getHost, hosts, machineEnrollments } from "@bb/db"; +import { eq } from "drizzle-orm"; +import { z } from "zod"; +import type { + ServerAccessGrant, + ServerAccessSelection, +} from "@get-bb/plugin-sdk"; +import type { WorkSessionDeps } from "../../types.js"; +import { + invokeServerAccessProvider, + listServerAccessProviders, +} from "../plugins/plugin-server-access-registry.js"; + +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 availabilitySchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("available") }), + z.object({ status: z.literal("setup-required"), message: z.string() }), + z.object({ status: z.literal("unavailable"), message: z.string() }), +]); + +export function machineServerUrl(deps: Dependencies) { + const configured = getAppSettings(deps.db).machineServerUrl; + const raw = configured ?? process.env.BB_EXTERNAL_URL ?? null; + const parsed = reachableUrlSchema.safeParse(raw); + return { + url: parsed.success ? parsed.data.replace(/\/$/u, "") : null, + source: + configured !== null + ? ("setting" as const) + : raw !== null + ? ("BB_EXTERNAL_URL" as const) + : null, + }; +} + +export async function serverAccessStatus(deps: Dependencies) { + const direct = machineServerUrl(deps); + const providers = await Promise.all( + listServerAccessProviders().map(async (record) => { + try { + const availability = availabilitySchema.parse( + await invokeServerAccessProvider(record, async () => + record.provider.availability(), + ), + ); + const attention = record.provider.experimental_attention + ? await invokeServerAccessProvider(record, async () => + record.provider.experimental_attention!(), + ) + .then((value) => z.string().nullable().parse(value)) + .catch(() => "Access diagnostics are unavailable") + : null; + return { + attention, + id: record.provider.id, + displayName: record.provider.displayName, + availability, + }; + } catch { + return { + attention: null, + id: record.provider.id, + displayName: record.provider.displayName, + availability: { + status: "unavailable" as const, + message: "Server access provider is unavailable", + }, + }; + } + }), + ); + providers.push({ + attention: null, + id: "direct", + displayName: "Direct URL", + availability: + direct.url === null + ? { + status: "setup-required", + message: "Set a server URL reachable by machines", + } + : { status: "available" }, + }); + const configured = getAppSettings(deps.db).defaultMachineAccess; + const defaultProviderId = + configured ?? + (providers.some( + (entry) => + entry.id === "connect" && entry.availability.status === "available", + ) + ? "connect" + : direct.url !== null + ? "direct" + : null); + return { + providers, + defaultProviderId, + effectiveUrl: direct.url, + urlSource: direct.source, + }; +} + +async function resolve( + deps: Dependencies, + args: { + key: string; + hostId: string; + access?: ServerAccessSelection; + signal: AbortSignal; + }, +): Promise { + args.signal.throwIfAborted(); + const host = getHost(deps.db, args.hostId); + if (!host || host.destroyedAt !== null) + throw new Error("Machine identity is unavailable"); + const status = await serverAccessStatus(deps); + const providerId = + host.serverAccessProviderId ?? + args.access?.providerId ?? + status.defaultProviderId; + if ( + args.access && + host.serverAccessProviderId && + args.access.providerId !== host.serverAccessProviderId + ) { + throw new Error("Machine already has a different server access provider"); + } + const available = status.providers.find((entry) => entry.id === providerId); + if (!available || available.availability.status !== "available") { + throw new Error( + (available?.availability.status !== "available" && + available?.availability.message) || + "Configure default machine access in General settings", + ); + } + 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"); + deps.db + .update(hosts) + .set({ serverAccessProviderId: providerId }) + .where(eq(hosts.id, args.hostId)) + .run(); + let result: ServerAccessGrant; + try { + result = await invokeServerAccessProvider(record, () => + record.provider.acquire(args), + ); + } catch (error) { + deps.db + .update(hosts) + .set({ + teardownMessage: + error instanceof Error ? error.message : String(error), + }) + .where(eq(hosts.id, args.hostId)) + .run(); + deps.hub.notifyHost(args.hostId, ["host-connected"]); + throw error; + } + const parsed = grantSchema.safeParse(result); + if (!parsed.success) + throw new Error("Server access provider returned an invalid grant"); + grant = parsed.data; + } + 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, + teardownMessage: null, + }) + .where(eq(hosts.id, args.hostId)) + .run(); + args.signal.throwIfAborted(); + return grant; +} + +async function release( + deps: Dependencies, + args: { key: string; hostId: string }, +) { + const enrollment = deps.db + .select({ owner: machineEnrollments.owner, key: machineEnrollments.key }) + .from(machineEnrollments) + .where(eq(machineEnrollments.hostId, args.hostId)) + .get(); + const acquisitionKey = enrollment + ? JSON.stringify([enrollment.owner, enrollment.key]) + : args.key; + const host = getHost(deps.db, args.hostId); + if (!host) return; + const providerId = + host.serverAccessProviderId ?? + (host.machineProviderId === "manual" && host.connectMachineId !== null + ? "connect" + : null); + const grantId = + host.serverAccessGrantId ?? + (host.connectMachineId === null ? null : host.id); + if (providerId === null) return; + if (providerId !== "direct") { + const record = listServerAccessProviders().find( + (entry) => entry.provider.id === providerId, + ); + if (!record) + throw new Error("Server access provider is unavailable for cleanup"); + 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 8d1f49818c..79be122c35 100644 --- a/apps/server/src/services/plugins/builtin-registry.ts +++ b/apps/server/src/services/plugins/builtin-registry.ts @@ -159,6 +159,11 @@ export const BUILTIN_PLUGINS = [ })); export const OFFICIAL_PLUGINS = [ + { + name: "machine-digitalocean", + pluginId: "machine-digitalocean", + 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..026073e474 100644 --- a/apps/server/src/services/plugins/plugin-agent-contributions.ts +++ b/apps/server/src/services/plugins/plugin-agent-contributions.ts @@ -81,12 +81,18 @@ export async function resolvePluginProviderEnv(args: { export async function resolvePluginProviderEnvHealth(args: { providerId: string; hostId: string; + threadId?: string | null; }) { const active = contributions; if (!active?.resolveProviderEnvHealth) return null; return active.resolveProviderEnvHealth({ providerId: args.providerId, - context: { hostId: args.hostId }, + context: { + hostId: args.hostId, + ...(args.threadId !== undefined + ? { experimental_readiness: { threadId: args.threadId } } + : {}), + }, }); } diff --git a/apps/server/src/services/plugins/plugin-api.ts b/apps/server/src/services/plugins/plugin-api.ts index a7c634fdf3..63e4c999a5 100644 --- a/apps/server/src/services/plugins/plugin-api.ts +++ b/apps/server/src/services/plugins/plugin-api.ts @@ -1,3 +1,6 @@ +import { createMachineBootstrapApi } from "../machines/bootstrap.js"; +import type { MachineEnrollments } from "@get-bb/plugin-sdk"; +import { listServerAccessProviders } from "./plugin-server-access-registry.js"; import { createHash } from "node:crypto"; import { mkdirSync } from "node:fs"; import { join } from "node:path"; @@ -42,6 +45,7 @@ import type { PluginMentionItem, PluginMentionSearchContext, PluginMentionTrigger, + PluginMachines, PluginAiServiceDeclaration, PluginAiServices, PluginProviderDeclaration, @@ -92,6 +96,7 @@ import { pluginHookAlreadyRegisteredMessage, storePluginHook, validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, providerAlreadyRegisteredMessage, providerIconRefusalMessage, undeclaredIconProblem, @@ -103,6 +108,7 @@ import { import type { AiServiceHostBinding, NormalizedPluginEnvironmentProvider, + NormalizedPluginMachineProvider, NormalizedPluginProviderDeclaration, } from "@get-bb/plugin-sdk/internal/host-policy"; import type { BbSdk, ThreadForkArgs, ThreadSpawnArgs } from "@bb/sdk"; @@ -250,6 +256,11 @@ export interface PluginApiHandle { /** Hook handlers recorded by `bb.experimental_hooks.on`. */ hooks: PluginHookRecords; 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 +434,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 +442,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 @@ -549,6 +562,11 @@ export function createPluginApi(options: { 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(); @@ -1539,6 +1557,76 @@ export function createPluginApi(options: { }, }; + const experimental_serverAccess: import("@get-bb/plugin-sdk").PluginServerAccess = + { + register(declaration) { + assertLive(); + if ( + !/^[a-z][a-z0-9-]*$/u.test(declaration.id) || + declaration.id === "direct" + ) { + throw new Error("Invalid or reserved server access provider id"); + } + if ( + !declaration.displayName.trim() || + typeof declaration.availability !== "function" || + typeof declaration.acquire !== "function" || + typeof declaration.release !== "function" + ) { + throw new Error("Invalid server access provider declaration"); + } + if ( + serverAccessProviders.has(declaration.id) || + listServerAccessProviders().some( + (entry) => + entry.provider.id === declaration.id && + entry.pluginId !== pluginId, + ) + ) { + throw new Error( + `Server access provider "${declaration.id}" is already registered`, + ); + } + serverAccessProviders.set(declaration.id, declaration); + }, + }; + + const enrollmentApi: MachineEnrollments = { + prepare(request) { + assertLive(); + return options.getMachineEnrollments().prepare(request); + }, + waitForConnection(request) { + assertLive(); + return options.getMachineEnrollments().waitForConnection(request); + }, + cancel(request) { + assertLive(); + return options.getMachineEnrollments().cancel(request); + }, + }; + const experimental_machines: PluginMachines = { + ...createMachineBootstrapApi(enrollmentApi), + register(declaration) { + assertLive(); + const provider = validatePluginMachineProviderDeclaration(declaration); + const problem = + provider.icon === null + ? null + : 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 +1657,8 @@ export function createPluginApi(options: { events, experimental_hooks, experimental_environments, + experimental_machines, + experimental_serverAccess, status, server, hosts, @@ -1599,6 +1689,8 @@ export function createPluginApi(options: { threadEventHandlers, hooks, environmentProviders, + machineProviders, + serverAccessProviders, httpRoutes, websocketRoutes, rpcHandlers, diff --git a/apps/server/src/services/plugins/plugin-host-rpc.ts b/apps/server/src/services/plugins/plugin-host-rpc.ts index 3cdc58da9d..b6ffd25150 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 { @@ -8,7 +9,7 @@ import type { import type { JsonValue } from "@bb/domain"; import { COMMAND_TIMEOUT_MS } from "../../constants.js"; import type { WorkSessionDeps } from "../../types.js"; -import { callHostOnlineRpc } from "../hosts/online-rpc.js"; +import { callHostOnlineRpcWithoutAdmission } from "../hosts/online-rpc.js"; import type { PluginHostArtifactSnapshot } from "./plugin-service-internal.js"; const HOST_RPC_TRANSPORT_GRACE_MS = 6_000; @@ -81,11 +82,15 @@ export async function callPluginHostRpc( ); const callId = randomUUID(); const timeoutMs = args.timeoutMs ?? COMMAND_TIMEOUT_MS; - const rpc = callHostOnlineRpc(deps, { + const rpc = callHostOnlineRpcWithoutAdmission(deps, { hostId: args.hostId, 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: { @@ -113,7 +118,7 @@ export async function callPluginHostRpc( }; const onAbort = (): void => { aborted = true; - void callHostOnlineRpc(deps, { + void callHostOnlineRpcWithoutAdmission(deps, { hostId: args.hostId, timeoutMs: HOST_RPC_TRANSPORT_GRACE_MS, command: { @@ -143,7 +148,7 @@ export async function disposePluginHostWorkers( const calls = listPublicHosts(deps.db) .filter((host) => deps.hub.hasDaemonForHost(host.id)) .map((host) => - callHostOnlineRpc(deps, { + callHostOnlineRpcWithoutAdmission(deps, { hostId: host.id, timeoutMs: HOST_RPC_TRANSPORT_GRACE_MS, command: { 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 0c79b5c0ce..6d956bc3e2 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; @@ -659,6 +662,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()) { @@ -1336,6 +1383,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) => { @@ -1360,6 +1414,14 @@ export function createPluginRuntime(context: PluginRuntimeContext) { } 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); }, @@ -1772,6 +1834,9 @@ export function createPluginRuntime(context: PluginRuntimeContext) { listPluginHooks, 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..eddeabac7a --- /dev/null +++ b/apps/server/src/services/plugins/plugin-server-access-registry.ts @@ -0,0 +1,31 @@ +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); +} diff --git a/apps/server/src/services/plugins/plugin-service-internal.ts b/apps/server/src/services/plugins/plugin-service-internal.ts index 0f3a2f6793..eeffd876dc 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, @@ -176,6 +178,7 @@ export interface PluginResolvedProviderEnv { } export interface PluginResolvedProviderEnvHealth { + experimental_probe?: { serverPath: string; headers: Record }; label: string; statusMessage: string; } diff --git a/apps/server/src/services/plugins/plugin-service.ts b/apps/server/src/services/plugins/plugin-service.ts index 6a8e7b80fe..547bddba2c 100644 --- a/apps/server/src/services/plugins/plugin-service.ts +++ b/apps/server/src/services/plugins/plugin-service.ts @@ -156,6 +156,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 +187,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. @@ -918,6 +921,9 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { listPluginHooks, listPluginEnvironmentProviders, getPluginEnvironmentProvider, + listPluginMachineProviders, + listPluginServerAccessProviders, + getPluginMachineProvider, isPackagedBuiltinEntry, loadAll, loaded, @@ -935,6 +941,7 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { withPluginOperationLock, } = createPluginRuntime({ deps, + machineEnrollments: deps.machineEnrollments ?? null, nextCronRunAt, settingsChanged: notifyPluginsChanged, settledWithin, @@ -1617,6 +1624,44 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { decisionTimeoutMs: pluginHookTimeoutMs, }, + serverAccessProviders: { + list: listPluginServerAccessProviders, + invoke: async (pluginId, run) => { + let recoveryMessage: string | null = null; + const outcome = await invokeWrapped( + pluginId, + "server access", + async () => { + try { + return await run(); + } catch (error) { + if ( + error instanceof Error && + error.name === "experimental_ServerAccessRecoveryError" + ) + recoveryMessage = error.message; + throw error; + } + }, + ); + if (!outcome.ok) + throw new Error(recoveryMessage ?? "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() { @@ -2170,6 +2215,24 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { return normalizeRpcJsonResult(parsedOutput); }); if (outcome.ok) return { ok: true, result: outcome.value }; + if ( + outcome.cause instanceof Error && + outcome.cause.name === "experimental_PluginRpcConflict" && + "latestRevision" in outcome.cause && + (outcome.cause.latestRevision === null || + (typeof outcome.cause.latestRevision === "number" && + Number.isSafeInteger(outcome.cause.latestRevision) && + outcome.cause.latestRevision >= 0)) + ) { + return { + ok: false, + error: { + code: "conflict", + message: outcome.cause.message, + latestRevision: outcome.cause.latestRevision, + }, + }; + } if (outcome.cause instanceof PluginRpcBoundaryError) { return { ok: false, error: outcome.cause.rpcError }; } @@ -2231,6 +2294,9 @@ export function createPluginService(deps: PluginServiceDeps): PluginService { return enforcePluginCliOutputLimit( { exitCode: result.exitCode, + ...(result.experimental_continue + ? { experimental_continue: result.experimental_continue } + : {}), stdout: typeof result.stdout === "string" ? result.stdout : "", stderr: typeof result.stderr === "string" ? result.stderr : "", }, 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..82fafdfd27 --- /dev/null +++ b/apps/server/src/services/projects/project-source-setup.ts @@ -0,0 +1,190 @@ +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 { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; +import { runLiveHostCommand } from "../hosts/live-command.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; + }, +) { + if (!args.remoteUrl) { + throw new ApiError( + 400, + "missing_git_remote", + "A remoteUrl is required because this project has no git remote anchor", + ); + } + const resolved = await runLiveHostCommand(deps, { + hostId: args.hostId, + timeoutMs: 20 * 60 * 1000, + command: { + type: "project.clone", + contributedEnv: await resolveHostEnvironment(deps, { + hostId: args.hostId, + projectId: args.projectId, + }), + remoteUrl: args.remoteUrl, + projectSlug: args.projectName, + ...(args.targetPath !== undefined ? { targetPath: args.targetPath } : {}), + }, + }); + return registerProjectSourceOnHost(deps, { + projectId: args.projectId, + hostId: args.hostId, + ...resolved, + ownsPath: true, + }); +} + +interface EnsureProjectSourceArgs { + projectId: string; + projectName: string; + hostId: string; + remoteUrl: string | null; +} + +const pendingSetups = new WeakMap< + CommandResultSideEffectsDeps["db"], + Map>> +>(); + +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; + const setup = recoverOrCloneProjectSource(deps, args); + pending.set(key, setup); + try { + return await setup; + } finally { + 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 callHostRetryableOnlineRpc(deps, { + hostId: args.hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { + type: "project.clone_default_path", + projectSlug: `project-${args.projectId}`, + }, + }); + const { existence } = await callHostRetryableOnlineRpc(deps, { + hostId: args.hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { type: "host.paths_exist", paths: [path] }, + }); + if (existence[path] === true) { + const inspected = await callHostRetryableOnlineRpc(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/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index c2c220fa54..2ba0a54cac 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -57,6 +57,25 @@ 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 launch ID and command. + Cancel with `bb machine cancel `. Removal revokes access; uninstall + manually on that box with `bb machine uninstall --host-id `. +- 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 its picker-sugar environment with + `bb thread spawn --new-machine `; add + `--machine-inputs ` when its schema requires inputs. Machine inputs are + persisted and non-secret; credentials belong in plugin settings. For a provider + without an environmentRow, including SSH, add `--environment-provider `. +- Use `bb machine enroll` for a private core-prepared bundle and local + `bb machine start|stop|uninstall --host-id ` for an owned installation; + see references/thread-creation.md for isolation and 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. Read or set `managedBranchPrefix` through `bb settings show` and `bb settings general `. @@ -103,3 +122,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. Connection credentials are written with `connection --output ` and work only on the browser host; keep them out of chat and public port shares. + +`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/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md index 81514f8b7e..fa058b50f4 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/app-settings.md @@ -123,3 +123,11 @@ every window and client sees the same value. - Enable it with `bb settings experiment timelineWindowing true`. - It keeps stable timeline wrappers while mounting only rows near the active main or nested detail scrollport. + +Machine access: `bb settings general machineServerUrl https://bb.example.com` +sets the server URL reachable by machines. Set `null` to use BB_EXTERNAL_URL. +`bb settings general defaultMachineAccess direct` selects direct access; +`connect` selects bb Cloud; `null` selects paired Connect, otherwise direct +when a URL exists. `bb settings show --json` includes serverAccess with the +effective URL, its source and provider availability. These grants carry runtime +requests, including account-pool traffic, after enrolment. diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/references/command-index.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/command-index.md index fd8dd303e9..d020a7d742 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/command-index.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/command-index.md @@ -62,16 +62,38 @@ This index lists every command path that the core CLI registers. Read the task-s ## machine - `bb machine` +- `bb machine lifecycle` +- `bb machine ready` +- `bb machine providers` +- `bb machine enroll` +- `bb machine env` +- `bb machine env list` +- `bb machine env set` +- `bb machine env unset` +- `bb machine start` +- `bb machine stop` +- `bb machine uninstall` +- `bb machine create` +- `bb machine status` +- `bb machine cancel` - `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. Add `--environment-provider ` when the machine provider has no +`environmentRow` (including SSH). `--machine-inputs ` configures the machine; +`--environment-inputs ` configures the workspace. Neither carries secrets. + ## updates - `bb updates` @@ -266,3 +288,8 @@ This index lists every command path that the core CLI registers. Read the task-s - `bb browser close` - `bb browser capture` - `bb browser watch` + +Machine creation is durable: `create --no-wait` returns the launch ID, `status ` polls it, and `cancel ` explicitly cancels it. SIGINT only stops following. Following continues through retryable failures until ready or terminal failure; launch status exposes `terminal`. + +Machine environment: `bb machine env list`, `bb machine env set NAME --secret` +(value from stdin), and `bb machine env unset NAME`; all accept `--json`. diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/references/configuration.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/configuration.md index 05ff03beb4..1608618ca2 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/configuration.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/configuration.md @@ -78,3 +78,32 @@ machines only when more than one is enrolled. - `bb skill cli-skills-status` reports per machine whether the installed copy is `installed`, `outdated`, `missing`, or `unknown` (disconnected or unreachable). + +## Machine access and isolated data + +General `machineServerUrl` is the URL reachable by machines; unset uses +`BB_EXTERNAL_URL`. `defaultMachineAccess` selects an access provider; unset +prefers paired Connect, then direct when a URL exists. Inspect effective values +with `bb settings show --json` and change them with `bb settings general`. +`BB_DATA_DIR` selects isolated enrollment state. Local machine lifecycle commands +treat it as an ownership assertion and refuse the default BB installation; see +thread-creation.md and docs/configuration.md for the directory constraints. + +Machine enrollment v2 stores private `serverHeaders` in machine `config.json`. +The launcher transports these through `BB_SERVER_HEADERS` (JSON string map) for +all server requests. Do not print these headers; they can contain access tokens. + +## Machine environment + +Use `bb machine env list --json` for variables and built-in gh health. +`bb machine env set NAME [--secret] [--note text] --json` reads the value from +stdin and removes one trailing newline; never pass secrets in argv or print +them. `bb machine env unset NAME --json` removes an override. GH_TOKEN is always +secret and all secret values are omitted from 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 → General → +Machine environment, and `bb settings show --json` for machineGit readiness. diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/references/thread-creation.md b/apps/server/src/services/skills/builtin-skills/bb-cli/references/thread-creation.md index 21e482a04d..1219fc6b26 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/references/thread-creation.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/references/thread-creation.md @@ -20,6 +20,13 @@ `--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 [--project ]`. Create a + new provider machine and its advertised environment row with + `bb thread spawn --new-machine `. Pass + `--environment-provider ` when the machine provider has no environmentRow, + including SSH. Pass `--machine-inputs ` when required. 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`. @@ -79,8 +86,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 @@ -163,9 +170,57 @@ 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 ] +[--project ] [--no-wait] [--json]` creates a machine without a thread. Omit project +for global creation; an explicit project accepts its exact name or ID. 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 launch ID; `bb machine status +` inspects it and `bb machine cancel ` explicitly cancels +it. SIGINT stops following and exits with status 130 while creation continues. +Following tolerates retryable failures until ready or terminal failure. + +`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 + +`bb machine 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 built-in `manual` provider appears as Existing machine. `bb machine create --provider manual` prints the enrollment command and follows; `--no-wait` returns the launch ID and a transient `command` field, separate from credential-free durable progress. Commands are no longer available after enrollment or cancellation. Manual machines never suspend or retire automatically. Removal revokes access; run `bb machine 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. +Use `bb machine ready MACHINE --provider PROVIDER --project PROJECT_ID --json` +to check CLI installation, credential-route reachability and checkout setup before +a turn. A blocked result names cli/auth/workspace and the actionable failure. +Provider-created machine turns run these checks automatically. A connected daemon +alone does not establish agent readiness. + + +`bb machine lifecycle MACHINE --json` shows the vendor expiry, planned maintenance, +last successful snapshot, recovery state and automatic retention removal deadline. +Use `--keep` to prevent automatic retention removal, or `--no-keep` to restore it. +Maintenance interrupts active turns and closes terminals before saving. Submit a +new continuation turn after restore; interrupted turns are never reported successful. + +`bb machine lifecycle MACHINE --remove --yes --json` removes retained compute and snapshots through the normal machine removal path. `--keep` prevents automatic retention deletion; `--no-keep` restores it. After filesystem restore, core reruns the owned checkout’s idempotent setup hook to restart services; hook failure blocks readiness. diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md index d6748ed71d..af8cae05ef 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/SKILL.md +++ b/apps/server/src/services/skills/builtin-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/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md index b37f868a23..9b8a17c098 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-api-index.md @@ -9,6 +9,7 @@ Read the installed declarations for exact current signatures. - `PLUGIN_CLI_OUTPUT_MAX_BYTES` - `defineRpcContract` - `experimental_defineHostEntry` +- `experimental_PluginRpcConflict` - `BbContext` - `BbNavigate` - `BbPluginApi` @@ -73,6 +74,24 @@ Read the installed declarations for exact current signatures. - `PluginDispatchExecutionSources` - `PluginEnvironments` — `bb.experimental_environments`: `register` + `recheck` (see backend-events.md, environment providers) +- `PluginServerAccess` — `bb.experimental_serverAccess.register` +- `ServerAccessProviderDeclaration` +- `ServerAccessGrant` +- `ServerAccessSelection` +- `PluginMachines` — `bb.experimental_machines.register` and enrollment/bootstrap helpers (see backend-machines.md) +- `EnrollmentBootstrap` — private versioned enrollment bundle +- `MachineEnrollment` — pending bundle or enrolled host identity +- `MachineExecutorRequest` — argv, timeout, signal, optional private stdin +- `MachineExecutor` — transport exec and optional writeFile +- `MachineEnrollmentRequest` — durable key and optional access selection +- `MachineConnectionRequest` — enrollmentId, timeoutMs, signal +- `MachineEnrollments` — prepare, waitForConnection, cancel +- `MachineBootstrapRequest` — enrollment request, executor, daemon mode, report, signal +- `MachineInstallerCommand` — command argv and private stdin +- `MachineBootstrapApi` — enrollments, prepareEnrollment, waitForConnection, installerCommand, bootstrap +- `PluginMachineProviderDeclaration` +- `PluginMachineProviderRequirements` — optional `gitRemote` +- `PluginMachineValidateDecision` - `PluginEnvironmentProviderDeclaration` - `PluginEnvironmentProviderRequirements` — `requires`, e.g. `{ gitCheckout: true }`; also `projectCheckout`, `gitRemote` and `projectless`. @@ -113,6 +132,7 @@ Read the installed declarations for exact current signatures. - `PluginCliOutputLimitError` - `PluginCliRegistration` - `PluginCliResult` +- `experimental_PluginCliContinuation` - `PluginCodeThemeData` - `PluginCodeThemeState` - `PluginCodeThemeTokenRule` @@ -279,6 +299,27 @@ 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, optional icon, inputs, availability, + validation, optional picker sugar, policy, create, optional paired + suspend/resume, and remove +- `PluginMachineProviderInputsSchema` +- `PluginMachineProviderPolicy` — idle suspension, retirement, and removal retry +- `PluginMachineProviderEnvironmentRow` +- `PluginMachineProviderAvailabilityContext` +- `PluginMachineProviderAvailability` +- `PluginMachineProviderValidateContext` +- `PluginMachineProviderCreateContext` — async `checkpoint(resource)` after + preparing enrollment and allocating, before bootstrap; never bundle credentials +- `PluginMachineProviderCreateResult` +- `PluginMachineProviderLifecycleContext` +- `PluginMachineProviderSuspendContext` — suspend context with a durable + `checkpoint` resource callback +- `PluginMachineProviderProgress` +- `PluginMachineProviderResourceResult` +- `PluginMachineProviderRemoveResult` + ## `@get-bb/plugin-sdk/ai-services` - `experimental_aiInferenceCompleteInputSchema` @@ -297,6 +338,7 @@ Read the installed declarations for exact current signatures. ## `@get-bb/plugin-sdk/host` - `experimental_defineHostEntry` +- `experimental_PluginRpcConflict` - `experimental_filterResolvedNativeRoots` - `experimental_killProcessesWithCwdUnder` — reap processes whose cwd is under a workspace a provider is tearing down, before removing the directory diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-foundation.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-foundation.md index f654766f45..2faf84c15d 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-foundation.md +++ b/apps/server/src/services/skills/builtin-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/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-machines.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-machines.md new file mode 100644 index 0000000000..17f7f17272 --- /dev/null +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-machines.md @@ -0,0 +1,181 @@ +# Machine providers and server access + +### Machine providers: core-owned machines + +Register machine resource operations with `bb.experimental_machines.register`. +Machine providers compose with environment providers: a picker sugar row 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.create` can instead create +a standalone machine with `project: null`; create is not required to enrol a +project source in that case. + +`icon` is optional. Omit it when provider-created machines should look like +ordinary enrolled machines: the Machines page and Add machine show neither a +provider logo nor a provider badge. Declaring it enables the normal provider +glyph, plugin-relative SVG, declared icon, or React icon-slot presentation. + +```ts +bb.experimental_machines.register({ + id: "custom-machine", + displayName: "Custom machine", + icon: "Server", + inputs: z.object({ target: z.string() }), + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 60_000, + }, + async create({ inputs, key, checkpoint, report, signal }) { + const enrollment = await bb.experimental_machines.prepareEnrollment({ key }); + const target = await allocateTarget({ target: inputs.target, key, signal }); + const resource = { target: target.id, hostId: enrollment.hostId }; + await checkpoint(resource); + const { hostId } = await bb.experimental_machines.bootstrap({ + key, + executor: target.executor, + daemon: { kind: "install" }, + report, + signal, + }); + return { status: "created", hostId, resource }; + }, + async remove({ resource }) { + const owned = z.object({ target: z.string(), hostId: z.string() }).parse(resource); + await disconnectTarget(owned.target); + return { status: "removed" }; + }, +}); +``` + +`requires.gitRemote` makes the remote non-null when a project is supplied and +filters out projects without one. Optional Standard Schema `inputs` are parsed +before create and persisted in `hosts.machine_provider_selection`. Every plugin +can read them, so never put secrets there. Store credentials in plugin settings +and pass a non-secret reference such as a target name in inputs. + +Create receives a nullable project, nullable gitRemote, 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 returns the already-enrolled host instead of creating another resource. +Prepare enrollment before calling `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 the host id plus a private JSON resource +for later lifecycle operations. `allocateTarget` and `disconnectTarget` above +stand for provider-owned allocation, transport, and idempotent cleanup; removal +must handle a checkpointed target whose daemon was never installed or enrolled. +Core owns enrollment, identity files, and daemon installation internals. +A definitive rejection before allocation returns `{ status: "failed", failure: +"terminal", allocation: "none", message }`; persist that rejection first. Core +skips allocation reconciliation and settles enrollment/access/the pending host. +Omit `allocation` for unknown outcomes such as timeouts. Automatic unresolved +cleanup retries are bounded to a 30-minute launch window; unresolved cleanup +remains recorded for operator reconciliation. + +An `environmentRow` is optional. Providers without one, such as SSH, require +`--environment-provider ` alongside `bb thread spawn --new-machine `. + +Suspend and resume are optional but must be declared together. Without them, +`policy.idleSuspendMs` must be null. With them, core suspends only after every +live thread is idle and no terminal is open, then resumes before the next send. +Suspend receives `checkpoint(resource)`, which synchronously +persists a recoverable private resource before destructive cleanup. Use it +after creating a recovery artifact and before terminating the live machine or +deleting an older artifact. A replay receives the last checkpoint. +Resume receives an awaitable `checkpoint(resource)`. Call it immediately after +restoring or allocating compute and before bootstrap. Core fences the provider +owner, lifecycle phase and persisted operation ID, and restart passes the last +checkpoint back with the same enrollment identity. A stale callback rejects. +Allocation checkpoints are recovery records, not filesystem saves: providers +must create any filesystem snapshot themselves. Daemon-connected is not +agent-ready; checkout setup and provider authentication still need to complete. + +Standalone `bb machine create` and `bb.sdk.hosts.create` submit a durable launch +and follow its progress. `create --no-wait` / `hosts.submit` return the launch ID; +`machine status` / `hosts.launch` poll it. Only `machine cancel` / `hosts.cancel` +explicitly cancel; closing a client or aborting its signal stops following. + +Retirement is either last-thread plus a grace period or never. Removal always +cascades through the machine's environment providers before machine remove; +failures persist and retry after `removeRetryMs`. + + +## Server access + +`bb.experimental_serverAccess.register` declares id, displayName, +availability, acquire({ key, hostId, signal }) returning a ServerAccessGrant, +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 v2 carries the headers; pending encrypted +v1 bundles are upgraded server-side on preparation. Host metadata stores the provider id and grant id; pending +bootstrap credentials are encrypted separately by core. +An Error named `experimental_ServerAccessRecoveryError` exposes its deliberate +user-safe recovery message through the plugin boundary; ordinary errors stay +redacted. Release receives a null grantId when acquire was interrupted. Core persists the +provider before acquisition and retries release by key and hostId. Keep intent +and credential-bearing grants in secret storage; only non-secret revocation +metadata belongs in KV. Delivered v1 bundles upgrade locally to v2 headers in the +CLI and installer, including one-time legacy Connect redemption. + +`experimental_attention()` optionally returns a user-safe diagnostic or null, +synchronously or asynchronously. General settings displays it independently of +availability; never include credentials or raw provider payloads. + +General settings select the default. Plugins can pass ServerAccessSelection +to the machine enrolment/bootstrap APIs. The direct provider reads +machineServerUrl, falling back to BB_EXTERNAL_URL. Declaring a URL does not +prove reachability from a sandbox. + +### Machine enrollment and bootstrap + +`bb.experimental_machines` implements `MachineBootstrapApi` alongside register: + +- `enrollments.prepare({ key, access? })` and `prepareEnrollment` return a + `MachineEnrollment`: pending with a private `EnrollmentBootstrap` and expiry, + or enrolled with the stable hostId. Keys are scoped to the calling plugin. +- `enrollments.waitForConnection({ enrollmentId, timeoutMs, signal })` and + `waitForConnection` return `{ hostId }` after the daemon connects. +- `enrollments.cancel({ enrollmentId })` cancels pending enrollment and releases + its access; an already-enrolled identity retains its credentials and access. + This does not replace provider cleanup of an allocated resource. +- `installerCommand(bootstrap)` synchronously returns `MachineInstallerCommand` + `{ command: string[], stdin: string }`. Pass stdin privately; never place the + bundle in argv, logs, progress, or persisted machine resources. +- `bootstrap({ key, executor, access?, daemon, report, signal })` prepares or + recovers enrollment, installs or enrolls, starts the daemon, waits for its + connection, and returns `{ hostId }`. Reuse the same key and access selection + used before the create checkpoint. `daemon` is `{ kind: "install" }` or + `{ kind: "preinstalled" }`; the latter needs compatible `bb` and `bb-app`. + Install needs Node, npm, and curl; the helper does not install OS packages. + +A `MachineExecutor` implements `exec({ command, timeoutMs, signal, stdin? })` +returning `{ exitCode, stdout, stderr }`. Execute argv through the provider's +transport, honor timeout and cancellation, and keep stdin private. Optional +`writeFile(path, contents, mode?)` is available to callers; bootstrap uses exec. +The helper suppresses remote output and reports fixed progress messages. It +restarts enrolled identities, including a restored preinstalled snapshot. +Create's awaited checkpoint precedes bootstrap; suspend's synchronous checkpoint +persists a recovery artifact before destructive cleanup. + +### Finite machine lifetimes + +Optional `experimental_observe({hostId,resource,signal})` returns +`{state:"running"|"suspended"|"missing"|"unknown",expiresAt,resource}` without +allocating or changing identity. `experimental_policy({hostId,resource})` returns +live `{idleSuspendMs,retireAfterMs,deadlineLeadMs}`; null disables a policy. +Core owns the maintenance lease, dispatch exclusion, interruption, retention +warning and keep control. Stop workspace writers before snapshotting. Supply +`suspend.checkpoint(resource, experimental_snapshotAt)` after a successful save +and before terminating compute; do not report an allocation checkpoint as a save. +Reconcile resume by durable name and await its checkpoint before bootstrap. +Failed preservation must retain old compute and report recoverable failure. +`bb.sdk.hosts.experimental_lifecycle({hostId,keep?})` exposes the same lifecycle +state as `bb machine lifecycle MACHINE [--keep|--no-keep] --json`. diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-sdk.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-sdk.md index 70efd4161f..e4008ac232 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-sdk.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/backend-sdk.md @@ -23,7 +23,7 @@ signatures (see "Looking up the exact API"). | `threadSections` | `list` `create` `update` `delete` | | `projects` | `list` `get` `create` `update` `delete` `reorder` `paths` `files` `fileContent` `branches` `commands` `defaultExecutionOptions` `promptHistory` `sidebarBootstrap`; sub-areas `attachments` (`upload` `read` `copy`), `sources` (`add` `update` `delete`) | | `environments` | `list` `listProviders` `get` `update` `delete` `status` `paths` `commit` `archiveThreads` `diff` `diffFile` `diffFiles` `diffBranches` `diffPatch` `pullRequest` `markPullRequestDraft` `markPullRequestReady` `mergePullRequest` | -| `hosts` | `create` `list` `listProviders` `get` `update` `delete` `directory` `pathsExist` `pickFolder` `cloneDefaultPath` `createJoinCode` `suspend` `resume` `retryCleanup` `retryUpdate` `providerCliStatus` `installProviderCli` | +| `hosts` | `create` `list` `listProviders` `get` `update` `delete` `directory` `pathsExist` `pickFolder` `cloneDefaultPath` `createJoinCode` `suspend` `resume` `retryCleanup` `retryUpdate` `providerCliStatus` `installProviderCli` `experimental_ensureReady` | | `files` | `read` `write` `list` `listPaths` `mkdir` `move` `remove` `createPreview` | | `terminals` | `list` `create` `get` `input` `output` `resize` `rename` `restart` `close` | | `providers` | `list` `models` | @@ -172,3 +172,17 @@ 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.listProviders({ projectId? })` discovers machine providers and their +input schemas. `bb.sdk.hosts.create({ machineProviderId, projectId, inputs, key?, +signal? })` returns a public Host; use `projectId: null` for a global machine and +`inputs: null` only when the provider accepts no inputs. Supply a stable key for +idempotent retries. Creation does not create an environment or a thread. +`bb.sdk.hosts.suspend({ hostId })` and `resume({ hostId })` require the provider's +paired suspend/resume operations. `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/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-api-index.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-api-index.md index 0a7c663d5c..85d354a757 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-api-index.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-api-index.md @@ -41,6 +41,12 @@ Read the installed SDK declarations for the exact current signatures. ## Type exports +- `PluginMachineProviderInputsRegistration` — registers one provider's app + inputs control with `app.slots.experimental_machineProviderInputs` +- `PluginMachineProviderInputsProps` — project, persisted non-secret value, + and ready/blocked change callback +- `PluginMachineProviderInputsChange` + - `PluginHomepageSectionProps` - `PluginSettingsSectionProps` - `ExperimentalAppOverlayProps` @@ -49,7 +55,7 @@ Read the installed SDK declarations for the exact current signatures. - `PluginNewThreadPanelProps` - `PluginPendingInteractionView` - `PluginPendingInteractionProps` -- `BranchPickerProps` +- `ExperimentalBranchPickerProps` - `UseBranchesArgs` - `BranchesState` - `UseCheckoutStateArgs` diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-components.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-components.md index f3f6751451..4563e0832b 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-components.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-components.md @@ -228,6 +228,19 @@ 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. + A machine provider may contribute an `environmentRow`; choosing it creates a + new machine and then runs the row's environment provider on that machine. + A machine provider may omit `icon`; Add machine and the Machines page then + render no provider logo or provider badge, matching a manually enrolled + machine. + Machine-provider inputs use + `app.slots.experimental_machineProviderInputs({ machineProviderId, +component })`. The component receives `{ projectId, value, onChange }` and + reports ready JSON or a blocked reason. The same control appears in the + picker sugar row and Settings → Machines → Add 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/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md index 81a426f3b6..9a7da63ffb 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-core-slots.md @@ -258,3 +258,5 @@ projectId, experimental_hostId? }` (nullable fields). The optional host ID always use the built-in preview, and a removed/disabled opener degrades back to it. Pair with `bb.sdk.files` (rpc from your server) to load and CAS-save the content. + +Machine input controls receive `experimental_agentProviderId` for the selected composer agent (null outside a composer; older hosts may omit it). Recheck image verification when it changes, and report blocked until the selected project/image is usable. Launch values must contain only non-secret configuration. diff --git a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-renderer-slots.md b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-renderer-slots.md index 564c6fcecc..1c741e7977 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-renderer-slots.md +++ b/apps/server/src/services/skills/builtin-skills/bb-plugin-authoring/references/frontend-renderer-slots.md @@ -135,3 +135,15 @@ providerId }`) and `Original`, the host's declarative base for the body — One registration per provider id per plugin; if two plugins claim one provider id the host keeps the first by plugin id and warns. See the `app.tsx` example in `references/providers.md`. + +## Machine provider inputs + +Register `app.slots.experimental_machineProviderInputs({ machineProviderId, +component })` for machine providers in Add machine and the new-thread picker. +`PluginMachineProviderInputsRegistration` associates the slot with the backend +provider. `PluginMachineProviderInputsProps` supplies nullable `projectId`, +`value`, and `onChange`. Emit a `PluginMachineProviderInputsChange` of +`{ status: "ready", value }` when valid or `{ status: "blocked", reason }` to +prevent submission. Core validates the value against the provider's inputs +schema. Inputs are persisted and visible to every plugin: never collect secrets +here. Use shared components and typography; keep vendor logic in the backend. diff --git a/apps/server/src/services/system/execution-options.ts b/apps/server/src/services/system/execution-options.ts index cf0e688df6..3effd637dc 100644 --- a/apps/server/src/services/system/execution-options.ts +++ b/apps/server/src/services/system/execution-options.ts @@ -18,7 +18,10 @@ import type { ProviderModelListMemoValue } from "../../lifecycle-dedupers.js"; import type { LoggedWorkSessionDeps } from "../../types.js"; import { COMMAND_TIMEOUT_MS } from "../../constants.js"; import { ApiError } from "../../errors.js"; -import { callHostRetryableOnlineRpc } from "../hosts/online-rpc.js"; +import { + callHostRetryableOnlineRpc, + callHostRetryableOnlineRpcWithoutAdmission, +} from "../hosts/online-rpc.js"; import { getHostPermissionCeiling } from "../hosts/permission-ceiling.js"; import { requireEnvironment } from "../lib/entity-lookup.js"; import { createProviderListingBudget } from "../providers/native-roots.js"; @@ -192,18 +195,19 @@ async function listInstalledPluginProviderInfos( const installed = cached ?? (async () => { - const result = await callHostRetryableOnlineRpc(deps, { - hostId, - timeoutMs: budget.remainingMs(), - command: { - type: "provider.health", - providerId: registration.info.id, - bridgeLaunch, + const result = await callHostRetryableOnlineRpcWithoutAdmission( + deps, + { + hostId, + timeoutMs: budget.remainingMs(), + command: { + type: "provider.health", + providerId: registration.info.id, + bridgeLaunch, + }, }, - }); - return ( - result.supported && result.health.status !== "not_installed" ); + return result.supported && result.health.status !== "not_installed"; })(); if (cached === undefined) { deps.providerRegistry.rememberInstalled(cacheKey, installed); diff --git a/apps/server/src/services/system/periodic-sweeps.ts b/apps/server/src/services/system/periodic-sweeps.ts index 19b6cdb406..a38b3675a5 100644 --- a/apps/server/src/services/system/periodic-sweeps.ts +++ b/apps/server/src/services/system/periodic-sweeps.ts @@ -1,4 +1,5 @@ import { sweepProviderLifecycles } from "../environments/provider-orchestration.js"; +import { sweepMachineLifecycles } from "../machines/provider-orchestration.js"; import { and, eq, isNull } from "drizzle-orm"; import { CLOSED_SESSION_ROW_RETENTION_MS, @@ -339,6 +340,7 @@ export async function runThreadLifecycleSweep( ): Promise { await runThreadProvisioningOrphanCleanupSweep(deps); await sweepProviderLifecycles(deps); + await sweepMachineLifecycles(deps); } async function runMachineAuthPruneSweep( @@ -400,6 +402,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, { backgroundDeadlines: 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 dc06785f9c..8f4b8ccfdd 100644 --- a/apps/server/src/services/system/provider-installations.ts +++ b/apps/server/src/services/system/provider-installations.ts @@ -3,11 +3,13 @@ import type { ProviderCliStatusResponse, } from "@bb/host-daemon-contract"; 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 { + callHostRetryableOnlineRpcWithoutAdmission, callHostRetryableOnlineRpc, + callHostOnlineRpc, isHostUnavailableApiError, } from "../hosts/online-rpc.js"; import { listSystemProviderInfos } from "./execution-options.js"; @@ -26,7 +28,7 @@ function canOmitProviderInstallationStatusError(error: unknown): boolean { } export async function getProviderInstallations( - deps: AppDeps, + deps: WorkSessionDeps, args: { hostId: string }, ): Promise { const deadline = Date.now() + PROVIDER_INSTALLATION_STATUS_TIMEOUT_MS; @@ -62,7 +64,7 @@ export async function getProviderInstallations( return null; } try { - const status = await callHostRetryableOnlineRpc(deps, { + const status = await callHostRetryableOnlineRpcWithoutAdmission(deps, { hostId: args.hostId, timeoutMs: Math.min(COMMAND_TIMEOUT_MS, remainingMs), command: { @@ -94,3 +96,90 @@ 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); + } +} + +export async function ensureProviderInstallation( + deps: WorkSessionDeps, + args: { hostId: string; providerId: string }, +): Promise<{ ready: boolean; message: string }> { + return serializeProviderInstallation(deps, args.hostId, async () => { + await deps.providerRegistry.whenProviderRegistered(args.providerId); + const registration = deps.providerRegistry.get(args.providerId); + const bridgeLaunch = resolveBridgeLaunchForProviderId( + deps, + args.providerId, + ); + if (!registration?.info.maintenance.installation || !bridgeLaunch) + return { + ready: false, + message: "This provider has no registered CLI installer", + }; + const status = () => + callHostRetryableOnlineRpc(deps, { + hostId: args.hostId, + timeoutMs: COMMAND_TIMEOUT_MS, + command: { + type: "provider.installation.status", + providerId: args.providerId, + bridgeLaunch, + }, + }); + let installed = await status(); + if (installed.installed && !installed.versionUnsupported) + return { + ready: true, + message: "Compatible provider CLI already installed", + }; + if (!installed.installAction) + return { + ready: false, + message: "The provider cannot install a compatible CLI on this host", + }; + const result = await callHostOnlineRpc(deps, { + hostId: args.hostId, + timeoutMs: 10 * 60 * 1000, + command: { + type: "provider.installation.run", + providerId: args.providerId, + action: installed.installAction.kind, + bridgeLaunch, + }, + }); + deps.providerRegistry.forgetInstalledKey(args); + installed = await status(); + return { + ready: + result.events.some( + (event) => event.type === "completed" && event.success, + ) && + installed.installed && + !installed.versionUnsupported, + message: "Provider CLI installation completed", + }; + }); +} diff --git a/apps/server/src/services/terminals/terminal-session-lifecycle.ts b/apps/server/src/services/terminals/terminal-session-lifecycle.ts index 332a646697..71aee96849 100644 --- a/apps/server/src/services/terminals/terminal-session-lifecycle.ts +++ b/apps/server/src/services/terminals/terminal-session-lifecycle.ts @@ -1,3 +1,4 @@ +import { resolveHostEnvironment } from "../hosts/host-environment.js"; import { randomUUID } from "node:crypto"; import { createTerminalSession, @@ -681,6 +682,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 } : {}), diff --git a/apps/server/src/services/threads/dispatch-attempt.ts b/apps/server/src/services/threads/dispatch-attempt.ts index efa6432756..0d4cc11345 100644 --- a/apps/server/src/services/threads/dispatch-attempt.ts +++ b/apps/server/src/services/threads/dispatch-attempt.ts @@ -115,7 +115,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( diff --git a/apps/server/src/services/threads/thread-archive.ts b/apps/server/src/services/threads/thread-archive.ts index 9cd8312a26..013fefcf73 100644 --- a/apps/server/src/services/threads/thread-archive.ts +++ b/apps/server/src/services/threads/thread-archive.ts @@ -2,6 +2,11 @@ import { cancelProviderLaunch, sweepProviderEnvironment, } from "../environments/provider-orchestration.js"; +import { + cancelMachineLaunch, + resolveThreadMachineLaunchKey, + sweepProviderMachine, +} from "../machines/provider-orchestration.js"; import { listLiveThreadsInEnvironment, listUnarchivedAssignedChildThreads, @@ -104,10 +109,21 @@ function archiveThreadWithLifecycleEffects( void cancelProviderLaunch(deps, archivedThread.id).catch((error) => deps.logger.warn({ error }, "Environment launch cancellation failed"), ); + void cancelMachineLaunch( + deps, + resolveThreadMachineLaunchKey(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 2ff752482d..772651d2d7 100644 --- a/apps/server/src/services/threads/thread-create.ts +++ b/apps/server/src/services/threads/thread-create.ts @@ -633,7 +633,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, @@ -646,7 +648,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 1ae8723b44..f9a4072779 100644 --- a/apps/server/src/services/threads/thread-environment-placement.ts +++ b/apps/server/src/services/threads/thread-environment-placement.ts @@ -1,6 +1,7 @@ import { findProjectEnvironmentByHostPath, getProjectSourceByHost, + projectSourceOwnsPath, type EnvironmentRow, } from "@bb/db"; import { z } from "zod"; @@ -10,6 +11,7 @@ import { PERSONAL_PROJECT_ID, isLocalPathProjectSource, type GitBranchSelection, + type EnvironmentMachineSelection, type JsonValue, } from "@bb/domain"; import type { @@ -44,6 +46,7 @@ import { resolveStableThreadRequestEnvironment, } from "./thread-request-eligibility.js"; import type { ThreadProvisionEnvironmentIntent } from "./thread-provisioning-context.js"; +import { prepareMachineProviderSelection } from "../machines/provider-orchestration.js"; type PlacementDeps = LoggedPendingInteractionWorkSessionDeps; @@ -149,10 +152,23 @@ 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") { + requireNonDestroyedHostWithStatus(deps, selection.machine.hostId); + if (requires.projectCheckout) { + requireSourceForHost(deps, projectId, selection.machine.hostId); + } + machine = selection.machine; + } else { + const prepared = await prepareMachineProviderSelection(deps, { + machineProviderId: selection.machine.machineProviderId, + projectId, + inputs: selection.machine.inputs, + }); + machine = { + ...selection.machine, + inputs: prepared.inputs, + }; } if (requires.projectless && projectId !== PERSONAL_PROJECT_ID) { refuseProviderSelection( @@ -230,7 +246,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, + args.projectId, + host.id, + checkout.path, + ), + } : null; if (requires.gitRemote && project.gitRemoteUrl === null) { throw new ApiError( diff --git a/apps/server/src/services/threads/thread-environment-providers.ts b/apps/server/src/services/threads/thread-environment-providers.ts index aefca79d5d..4988404a20 100644 --- a/apps/server/src/services/threads/thread-environment-providers.ts +++ b/apps/server/src/services/threads/thread-environment-providers.ts @@ -1,8 +1,13 @@ +import { projectSourceOwnsPath } from "@bb/db"; import { askProviderLaunch, cancelProviderLaunch, persistPendingProviderRequest, } from "../environments/provider-orchestration.js"; +import { + cancelMachineLaunch, + resolveThreadMachineLaunchKey, +} from "../machines/provider-orchestration.js"; import { getAppSettings, getEnvironment, @@ -50,6 +55,9 @@ import { advanceThreadProvisioning } from "./thread-provisioning.js"; import { toThreadResponseFromThread } from "./thread-runtime-display.js"; import { toEnvironmentResponse } from "../environments/environment-response.js"; import type { ThreadProvisioningDeps } from "./thread-provisioning-environment.js"; +import { askMachineLaunch } from "../machines/provider-orchestration.js"; +import { getMachineProvider } from "../plugins/plugin-machine-provider-registry.js"; +import { ensureProjectSourceOnHost } from "../projects/project-source-setup.js"; const PROVIDER_UNAVAILABLE_RETRY_MS = 30_000; @@ -176,6 +184,12 @@ export function cancelAbandonedProviderLaunches( void cancelProviderLaunch(deps, threadId).catch((error) => deps.logger.warn({ threadId, error }, "Environment cancellation failed"), ); + void cancelMachineLaunch( + deps, + resolveThreadMachineLaunchKey(deps, threadId), + ).catch((error) => + deps.logger.warn({ threadId, error }, "Machine cancellation failed"), + ); } export function cancelEnvironmentProviderLaunch( @@ -350,8 +364,48 @@ export async function resolveEnvironmentProvider( error instanceof Error ? error.message : String(error), ); } - const host = getNonDestroyedHostWithStatus(deps, selection.machine.hostId); + let machineLog = ""; + const machine = selection.machine; + const host = + machine.type === "existing" + ? getNonDestroyedHostWithStatus(deps, machine.hostId) + : await (async () => { + const machineRecord = getMachineProvider(machine.machineProviderId); + if (machineRecord === undefined) { + throw providerFailure( + intent.environmentProviderId, + record.pluginId, + `needs the "${machine.machineProviderId}" machine provider, which is not registered`, + ); + } + const machineDecision = askMachineLaunch(deps, { + key: resolveThreadMachineLaunchKey(deps, thread.id), + record: machineRecord, + projectId: thread.projectId, + inputs: machine.inputs, + }); + if (machineDecision.action === "reject") { + throw new ApiError( + 409, + "machine_provider_rejected", + machineDecision.message, + ); + } + if (machineDecision.action === "wait") { + recordWait(deps, { + context, + log: machineDecision.log, + reason: machineDecision.reason, + sendAt: machineDecision.sendAt, + thread, + }); + return null; + } + machineLog = machineDecision.log; + return machineDecision.host; + })(); if (host === null) { + if (selection.machine.type === "new") return { kind: "waiting" }; throw providerFailure( intent.environmentProviderId, record.pluginId, @@ -367,10 +421,41 @@ export async function resolveEnvironmentProvider( { details: { environmentProviderId: intent.environmentProviderId } }, ); } - const checkout = getProjectSourceByHost(deps.db, thread.projectId, host.id); + let checkout = getProjectSourceByHost(deps.db, thread.projectId, host.id); + if (machine.type === "new" && requires.projectCheckout && checkout === null) { + appendThreadProvisioningEvent(deps, { + threadId: thread.id, + environmentId: null, + provisioningId: context.state.provisioningId, + status: "active", + entries: launchEntries({ + ask, + log: undefined, + now: Date.now(), + step: { text: "Setting up project on machine", status: "started" }, + }), + }); + checkout = await ensureProjectSourceOnHost(deps, { + projectId: project.id, + projectName: project.name, + hostId: host.id, + remoteUrl: project.gitRemoteUrl, + }); + if (getThread(deps.db, thread.id)?.status !== "starting") { + throw new Error("Thread provisioning context is no longer active"); + } + } 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( @@ -425,7 +510,7 @@ export async function resolveEnvironmentProvider( if (decision.action === "wait") { recordWait(deps, { context, - log: decision.log, + log: machineLog + decision.log, reason: decision.reason, sendAt: decision.sendAt ?? null, thread, @@ -438,7 +523,7 @@ export async function resolveEnvironmentProvider( } const entries = launchEntries({ ask, - log: decision.log, + log: machineLog + decision.log, now: Date.now(), step: ask.lastStep === null diff --git a/apps/server/src/services/threads/thread-lifecycle.ts b/apps/server/src/services/threads/thread-lifecycle.ts index 04aef601d2..7a812e61b3 100644 --- a/apps/server/src/services/threads/thread-lifecycle.ts +++ b/apps/server/src/services/threads/thread-lifecycle.ts @@ -1345,6 +1345,7 @@ export async function stopThreadForCurrentState( deps: RequestThreadStopForCurrentStateDeps, thread: RequestThreadStopForCurrentStateThread, environment: RequestThreadStopForCurrentStateEnvironment | null, + options?: { requireStopped: true }, ): Promise { await revokeThreadDesktopBrowserControl(deps, thread.id); const hasLiveRuntime = @@ -1363,6 +1364,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, @@ -1416,6 +1418,7 @@ async function runAwaitedThreadStopCommand( deps: RequestThreadStopForCurrentStateDeps, args: { command: ThreadStopCommand; + requireStopped?: boolean; hostId: string; threadId: string; }, @@ -1433,6 +1436,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 75450d8f83..b22c6b3f9e 100644 --- a/apps/server/src/services/threads/thread-provisioning.ts +++ b/apps/server/src/services/threads/thread-provisioning.ts @@ -270,10 +270,7 @@ export function requestThreadTargetReprovision( environmentIntent: { type: "provider", environmentProviderId: args.provider.environmentProviderId, - machine: { - type: "existing", - hostId: args.environment.hostId, - }, + machine: args.provider.selection.machine, inputs: args.provider.selection.inputs, selectionResolved: true, produced: null, diff --git a/apps/server/src/services/threads/thread-runtime-config.ts b/apps/server/src/services/threads/thread-runtime-config.ts index 5930dc5a4b..5df4e38047 100644 --- a/apps/server/src/services/threads/thread-runtime-config.ts +++ b/apps/server/src/services/threads/thread-runtime-config.ts @@ -1,3 +1,8 @@ +import { + resolveHostEnvironment, + mergeHostAndProviderEnvironment, +} from "../hosts/host-environment.js"; +import { ensureHostReady } from "../machines/readiness.js"; import { getEnvironment, getHost, getProject } from "@bb/db"; import type { DynamicTool, @@ -228,14 +233,32 @@ export async function resolveThreadRuntimeCommandConfig( }, skillIdsByPlugin, }); - const contributedEnv = await resolvePluginProviderEnv({ - providerId: args.thread.providerId, - context: { - threadId: args.thread.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, + }, + }), + ); + if (host.machineProviderId !== null) { + const readiness = await ensureHostReady(deps, { + contributedEnv, hostId: host.id, - }, - }); + providerId: args.thread.providerId, + projectId: project.id, + threadId: args.thread.id, + path: workspacePath, + }); + if (readiness.status === "blocked") + throw new ApiError(409, readiness.code, readiness.message); + } const injectedSkillSources = resolveSkillCatalog(deps, { projectSkillSources, sharedSkillSources: sharedSkills.runtimeSources, 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..f59c1d926a 100644 --- a/apps/server/test/app/install-machine-script.test.ts +++ b/apps/server/test/app/install-machine-script.test.ts @@ -5,6 +5,7 @@ import { 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", @@ -73,7 +74,7 @@ function runScript( async function runScriptAsync( args: string[], fixture: Fixture, - env: Record = {}, + env: Record = {}, ): Promise<{ status: number | null; stderr: string; stdout: string }> { const child = spawn("sh", [SCRIPT_PATH.pathname, ...args], { env: createScriptEnv(fixture, env), @@ -145,7 +146,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 +273,32 @@ 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); @@ -316,6 +343,126 @@ 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, version: 1 }, { container: false, version: 2 }, { container: true, version: 1 }, { container: true, version: 2 }])("enrolls privately with portable service selection (%j)", ({ container, version }) => { + 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({ + version, + ...(version === 1 ? { client: { kind: "direct" } } : {}), + 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 +493,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"), ); @@ -832,18 +982,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 +1012,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 +1023,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 +1091,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 +1132,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 +1160,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 +1175,176 @@ 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", ); }); }); + +it.each([1, 2])( + "upgrades installer v%s direct and Connect payloads before artifact download", + (version) => { + for (const kind of ["direct", "connect"]) { + const fixture = createFixture(); + const source = readFileSync(SCRIPT_PATH, "utf8"); + const program = source.split("<<'NODE'\n")[1]?.split("\nNODE")[0]; + expect(program).toBeTruthy(); + const path = join(fixture.dataDir, "bootstrap.json"); + const payload = { + version, + hostId: "host-test", + serverUrl: "https://test.getbb.app", + credential: "bootstrap", + expiresAt: Date.now() + 60000, + ...(version === 1 + ? { + client: + kind === "direct" + ? { kind } + : { + kind, + machineCode: "legacy", + expiresAt: Date.now() + 60000, + }, + } + : kind === "connect" + ? { headers: { "x-bb-connect-machine": "private" } } + : {}), + }; + const fetch = `globalThis.fetch = async (url, init) => { if (String(url) !== "https://getbb.app/api/connect/redeem-machine" || JSON.parse(init.body).code !== "legacy") throw new Error("Unexpected redemption"); return Response.json({ credential: "private", serverUrl: "https://test.getbb.app" }); };\n`; + const result = spawnSync( + process.execPath, + ["--input-type=module", "-", path], + { + input: fetch + program, + encoding: "utf8", + env: { ...process.env, BB_ENROLLMENT: JSON.stringify(payload) }, + }, + ); + expect(result.status, result.stderr).toBe(0); + expect(JSON.parse(result.stdout)).toMatchObject({ version: 2 }); + expect(JSON.parse(result.stdout).headers).toEqual( + kind === "connect" ? { "x-bb-connect-machine": "private" } : undefined, + ); + writeFileSync(join(fixture.dataDir, "config.json"), JSON.stringify({ serverUrl: payload.serverUrl, serverHeaders: JSON.parse(result.stdout).headers })); + writeFileSync(join(fixture.dataDir, "host-id"), payload.hostId); + rmSync(path); + const retry = spawnSync( + process.execPath, + ["--input-type=module", "-", path], + { + input: + `globalThis.fetch = () => { throw new Error("Redeemed twice"); };\n` + + program, + encoding: "utf8", + env: { ...process.env, BB_ENROLLMENT: JSON.stringify(payload) }, + }, + ); + expect(retry.status, retry.stderr).toBe(0); + } + }, +); 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/helpers/commands.ts b/apps/server/test/helpers/commands.ts index 25bc15d748..5537ace364 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", }; @@ -376,6 +374,24 @@ export function registerTestHostRpcCapture( }); return; } + if (command.type === "workspace.readiness.inspect") { + deps.hub.recordHostOnlineRpcResponse({ + message: hostDaemonOnlineRpcResponseMessageSchema.parse({ + type: "host-rpc.response", + requestId: message.requestId, + commandType: command.type, + ok: true, + result: { + commit: "fixture-commit", + dirty: [], + files: [], + abi: "linux/x64/node-127", + }, + }), + sessionId: args.sessionId, + }); + return; + } if ( command.type === "environment.hook.run" || command.type === "environment.hook.cancel" @@ -554,7 +570,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) { @@ -587,7 +603,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/machine-readiness.ts b/apps/server/test/helpers/machine-readiness.ts new file mode 100644 index 0000000000..6342c0e073 --- /dev/null +++ b/apps/server/test/helpers/machine-readiness.ts @@ -0,0 +1,44 @@ +import type { TestAppHarness } from "./test-app.js"; +import { + reportQueuedCommandSuccess, + waitForQueuedCommand, +} from "./commands.js"; + +export async function answerMachineReadiness(harness: TestAppHarness) { + const cli = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "provider.installation.status", + ); + await reportQueuedCommandSuccess(harness, cli, { + executableName: "codex", + executablePath: "/bin/codex", + installed: true, + installSource: "npmGlobal", + currentVersion: "1.0.0", + latestVersion: "1.0.0", + minimumSupportedVersion: "1.0.0", + npmPackageName: "codex", + npmGlobalPackageVersion: "1.0.0", + installAction: null, + needsUpdate: false, + versionUnsupported: false, + }); + const auth = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "provider.health", + ); + await reportQueuedCommandSuccess(harness, auth, { + supported: true, + health: { + status: "ready", + statusMessage: null, + accountEmail: null, + planLabel: null, + installedVersion: "1.0.0", + minimumSupportedVersion: "1.0.0", + canInstall: false, + canUpdate: false, + loginCommand: null, + }, + }); +} 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 4ec826fdee..0737f73920 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, 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/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..33f72874ee 100644 --- a/apps/server/test/internal/internal-session-protocol-version.test.ts +++ b/apps/server/test/internal/internal-session-protocol-version.test.ts @@ -10,19 +10,53 @@ import { } from "../helpers/test-app.js"; describe("internal session protocol version", () => { + 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 +66,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 +105,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 +132,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 +168,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 +186,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 +204,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/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 145bbfbcbf..3787452502 100644 --- a/apps/server/test/provider-corpus/corpus-harness.ts +++ b/apps/server/test/provider-corpus/corpus-harness.ts @@ -80,7 +80,6 @@ export function loadCorpusThreadIntoDb( 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-redaction.test.ts b/apps/server/test/public/machine-environment-redaction.test.ts new file mode 100644 index 0000000000..e0580837ee --- /dev/null +++ b/apps/server/test/public/machine-environment-redaction.test.ts @@ -0,0 +1,206 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { createConnection, migrate } from "@bb/db"; +import { threadEventSchema, type ThreadEvent } from "@bb/domain"; +import { createScriptedEchoRuntime } from "@bb/agent-runtime/test"; +import type { AgentRuntimeExecutionOptions } from "@bb/agent-runtime"; +import { expect, it, vi } from "vitest"; +import { z } from "zod"; +import { + resolveUserMachineEnvironment, + updateMachineEnvironment, +} from "../../src/services/machines/environment-settings.js"; + +const options: AgentRuntimeExecutionOptions = { + model: "test-model", + serviceTier: "default", + reasoningLevel: "medium", + providerOptions: {}, + permissionMode: "full", + permissionScope: "full", + approvalReviewer: null, + permissionEscalation: null, +}; + +async function withStoredSecret( + value: string, + run: ( + entries: Awaited>, + dir: string, + ) => Promise, +) { + const db = createConnection(":memory:"); + migrate(db); + const dir = await mkdtemp(join(tmpdir(), "bb-redaction-review-")); + try { + await updateMachineEnvironment(db, dir, "TEST_SECRET", { + name: "TEST_SECRET", + value, + secret: true, + note: null, + }); + await run(await resolveUserMachineEnvironment(db, dir), dir); + } finally { + db.$client.close(); + await rm(dir, { recursive: true, force: true }); + } +} + +async function echoSecret( + secret: string, + beforeAck = false, +): Promise { + const events: ThreadEvent[] = []; + const stderr: string[] = []; + let acknowledged = false; + let earlyDeltas = 0; + await withStoredSecret(secret, async (contributedEnv, workspacePath) => { + let complete: () => void = () => {}; + const completed = new Promise((resolve) => { + complete = resolve; + }); + const runtime = createScriptedEchoRuntime({ + runtime: { + workspacePath, + onStderr: (line) => stderr.push(line), + onEvent: (event) => { + events.push(event); + if (!acknowledged && event.type === "item/agentMessage/delta") + earlyDeltas += 1; + if (event.type === "turn/completed") complete(); + }, + }, + launch: { + scripted: { + textDeltaChunkSize: 20, + turnStartResponseDelayMs: beforeAck ? 1000 : undefined, + stderrChunksOnTurn: [secret.slice(0, 7), secret.slice(7) + "\n"], + }, + }, + }); + try { + await runtime.startThread({ + environmentId: "env-review", + projectId: "project-review", + threadId: "type", + providerId: "fake", + options, + contributedEnv: beforeAck ? [] : contributedEnv, + }); + await runtime.runTurn({ + threadId: "type", + clientRequestId: "creq_222222224c", + input: [{ type: "text", text: secret, mentions: [] }], + options, + contributedEnv, + }); + acknowledged = true; + if (beforeAck) expect(earlyDeltas).toBeGreaterThan(0); + await completed; + await vi.waitFor(() => expect(stderr.join("")).toContain("[redacted]")); + expect(stderr.join("")).not.toContain(secret); + } finally { + await runtime.shutdown(); + } + }); + return events; +} + +it("holds the stored token prefix across the reviewer's ghp_FAK and E_REVIEW_TOKEN deltas", async () => { + const events = await echoSecret("ghp_FAKE_REVIEW_TOKEN"); + const deltas = events.flatMap((event) => + event.type === "item/agentMessage/delta" ? [event.delta] : [], + ); + expect(deltas.join("")).toBe("Response to: [redacted]"); + expect(JSON.stringify(events)).not.toContain("ghp_FAK"); + expect(JSON.stringify(events)).not.toContain("E_REVIEW_TOKEN"); + expect(events.some((event) => event.type === "turn/completed")).toBe(true); +}, 15_000); + +it("redacts the stored secret type without changing protocol keys or identifiers or crashing", async () => { + const events = await echoSecret("type"); + expect( + events.every((event) => threadEventSchema.safeParse(event).success), + ).toBe(true); + expect(events.every((event) => event.threadId === "type")).toBe(true); + expect(events).toContainEqual( + expect.objectContaining({ + type: "item/completed", + item: expect.objectContaining({ + type: "agentMessage", + text: "Response to: [redacted]", + }), + }), + ); + expect(events).toContainEqual( + expect.objectContaining({ type: "turn/completed", status: "completed" }), + ); +}, 15_000); + +it("redacts a newly configured secret before the provider acknowledges the turn", async () => { + const events = await echoSecret("ghp_ROTATED_REVIEW_TOKEN", true); + expect(JSON.stringify(events)).not.toContain("ghp_ROTATED"); + expect(JSON.stringify(events)).not.toContain("REVIEW_TOKEN"); +}); + +it.each(["first-line\nsecond-line", "first-line\r\nsecond-line"])( + "redacts a stored multiline secret after real node-pty CRLF conversion at every chunk boundary: %j", + async (secret) => { + await withStoredSecret(secret, async (entries, dir) => { + const value = entries.find( + (entry) => entry.name === "TEST_SECRET", + )?.value; + expect(typeof value).toBe("string"); + if (typeof value !== "string") throw new Error("Missing stored secret"); + const script = ` + import { spawn } from 'node-pty'; + import { createSecretStreamRedactor } from '@bb/process-utils'; + const secret = process.env.TEST_SECRET; + const child = spawn('/bin/sh', ['-c', 'printf "%s\\\\n" "$TEST_SECRET"'], {name:'xterm-color', cols:80, rows:24, cwd:process.env.TEST_WORKSPACE, env:{PATH:'/usr/bin:/bin', TEST_SECRET:secret}}); + let raw = ''; + const live = createSecretStreamRedactor([secret]); + let output = ''; + child.onData(data => { raw += data; output += live.push(data); }); + child.onExit(() => { + output += live.flush(); + const outputs = Array.from({length: raw.length + 1}, (_, index) => { + const redactor = createSecretStreamRedactor([secret]); + return redactor.push(raw.slice(0,index)) + redactor.push(raw.slice(index)) + redactor.flush(); + }); + console.log(JSON.stringify({raw, output, outputs})); + }); + `; + const result = await promisify(execFile)( + process.execPath, + [ + "--conditions=source", + "--import", + "tsx", + "--input-type=module", + "--eval", + script, + ], + { + cwd: fileURLToPath(new URL("../../../host-daemon/", import.meta.url)), + env: { ...process.env, TEST_SECRET: value, TEST_WORKSPACE: dir }, + timeout: 10_000, + }, + ); + const proof = z + .object({ + raw: z.string(), + output: z.string(), + outputs: z.array(z.string()), + }) + .parse(JSON.parse(result.stdout)); + expect(proof.raw).toBe(secret.replaceAll("\n", "\r\n") + "\r\n"); + expect(proof.output).toBe("[redacted]\r\n"); + expect(new Set(proof.outputs)).toEqual(new Set(["[redacted]\r\n"])); + }); + }, + 15_000, +); 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..2ff5ed626c --- /dev/null +++ b/apps/server/test/public/machine-environment.test.ts @@ -0,0 +1,137 @@ +import { readFile, stat } from "node:fs/promises"; +import { join } from "node:path"; +import { + appSettingsValues, + machineEnrollments, + 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", + secret: true, + 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)), + }), + }); + await sdk.system.setMachineEnvironment({ + name: "DEPLOY_REGION", + value: "test-region", + secret: false, + note: "Gate", + }); + const result = await sdk.system.setMachineEnvironment({ + name: "GH_TOKEN", + value: "user-private-token", + secret: false, + 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", + ); + expect((await stat(path)).mode & 0o777).toBe(0o600); + expect(await readFile(path, "utf8")).toBe("user-private-token"); + 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", + }); + harness.db + .insert(machineEnrollments) + .values({ + id: "machine", + owner: "do", + key: "machine", + hostId: "machine", + state: "enrolled", + createdAt: 1, + updatedAt: 1, + }) + .run(); + 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", + secret: true, + }), + ]); + 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.unsetMachineEnvironment("GH_TOKEN"); + 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..bcd3c00740 100644 --- a/apps/server/test/public/public-host-management.test.ts +++ b/apps/server/test/public/public-host-management.test.ts @@ -13,8 +13,11 @@ 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 type { PluginMachineProviderDeclaration } from "@get-bb/plugin-sdk"; +import { validatePluginMachineProviderDeclaration } from "@get-bb/plugin-sdk/internal/host-policy"; +import { setPluginMachineProviderBridge } from "../../src/services/plugins/plugin-machine-provider-registry.js"; import { readJson } from "../helpers/json.js"; import { seedEnvironment, @@ -24,10 +27,46 @@ import { seedSession, seedThread, } from "../helpers/seed.js"; -import { withTestHarness } from "../helpers/test-app.js"; +import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; const API = "/api/v1"; +function installMachineProvider(declaration: PluginMachineProviderDeclaration) { + const record = { + pluginId: "public-host-management-test", + provider: validatePluginMachineProviderDeclaration(declaration), + }; + 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, + }); +} + +function adoptMachine(harness: TestAppHarness, hostId: string): void { + updateHost(harness.db, harness.hub, hostId, { + machineProviderId: "test-machine", + machineProviderSelection: { inputs: null }, + phase: "active", + resource: { machine: "test" }, + }); +} + +afterEach(() => { + setPluginMachineProviderBridge(undefined); +}); + async function createJoinCode( app: Parameters[0], ): Promise { @@ -49,6 +88,71 @@ function requestJoinCode(app: { } describe("public host management", () => { + it("publishes a machine provider without an icon as unbranded", async () => { + await withTestHarness(async (harness) => { + installMachineProvider({ + id: "plain-machine", + displayName: "Plain machine", + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 60_000, + }, + experimental_reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ + status: "created", + hostId: "host-plain", + resource: null, + }), + remove: async () => ({ status: "removed" }), + }); + + const response = await harness.app.request( + "/api/v1/system/machine-providers", + ); + expect(response.status).toBe(200); + expect(await readJson(response)).toMatchObject({ + providers: [ + { + id: "plain-machine", + icon: null, + logoUrl: null, + environmentRow: null, + }, + ], + }); + }); + }); + + 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 +168,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 +183,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 +206,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 +261,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 +299,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: { @@ -342,6 +456,116 @@ describe("public host management", () => { }); }); + it("routes suspend and resume through provider lifecycle orchestration", async () => { + await withTestHarness(async (harness) => { + const host = seedHost(harness.deps, { id: "host_lifecycle_routes" }); + const operations: string[] = []; + installMachineProvider({ + id: "test-machine", + displayName: "Test machine", + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 10, + }, + experimental_reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ + status: "created", + hostId: host.id, + resource: { machine: "test" }, + }), + suspend: async ({ resource }) => { + operations.push("suspend"); + return { resource }; + }, + resume: async ({ resource }) => { + operations.push("resume"); + return { resource }; + }, + remove: async () => ({ status: "removed" }), + }); + adoptMachine(harness, host.id); + + const suspend = await harness.app.request( + `${API}/hosts/${host.id}/suspend`, + { method: "POST" }, + ); + expect(suspend.status).toBe(200); + expect(await readJson(suspend)).toEqual({ ok: true }); + expect(getHost(harness.db, host.id)?.phase).toBe("suspended"); + + const resume = await harness.app.request( + `${API}/hosts/${host.id}/resume`, + { method: "POST" }, + ); + expect(resume.status).toBe(200); + expect(await readJson(resume)).toEqual({ ok: true }); + expect(getHost(harness.db, host.id)?.phase).toBe("active"); + expect(operations).toEqual(["suspend", "resume"]); + }); + }); + + it("retries cleanup only after a provider teardown failure", async () => { + await withTestHarness(async (harness) => { + const primary = seedHost(harness.deps, { id: "host_primary" }); + seedPrimaryHost(harness.deps, primary.id); + const host = seedHost(harness.deps, { id: "host_retry_cleanup" }); + let removes = 0; + installMachineProvider({ + id: "test-machine", + displayName: "Test machine", + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 60_000, + }, + experimental_reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ + status: "created", + hostId: host.id, + resource: { machine: "test" }, + }), + remove: async () => { + removes += 1; + return removes === 1 + ? { status: "failed", message: "temporary teardown failure" } + : { status: "removed" }; + }, + }); + adoptMachine(harness, host.id); + + const beforeFailure = await harness.app.request( + `${API}/hosts/${host.id}/retry-cleanup`, + { method: "POST" }, + ); + expect(beforeFailure.status).toBe(409); + expect(await readJson(beforeFailure)).toMatchObject({ + code: "machine_cleanup_not_failed", + }); + + const remove = await harness.app.request(`${API}/hosts/${host.id}`, { + method: "DELETE", + }); + expect(remove.status).toBe(200); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "retiring", + teardownStatus: "failed", + }); + + const retry = await harness.app.request( + `${API}/hosts/${host.id}/retry-cleanup`, + { method: "POST" }, + ); + expect(retry.status).toBe(200); + expect(await readJson(retry)).toEqual({ ok: true }); + expect(removes).toBe(2); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "destroyed", + teardownStatus: "removed", + }); + }); + }); + it("revokes host credentials, closes its live session, tombstones it, and preserves environments", async () => { await withTestHarness(async (harness) => { const primary = seedHost(harness.deps, { id: "host_primary" }); @@ -367,12 +591,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 +635,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..95354a6fb8 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 { machineEnrollments, 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,86 @@ 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", + secret: true, + }, + ]); + 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", + }); + harness.db + .insert(machineEnrollments) + .values({ + id: "machine", + owner: "do", + key: "machine", + hostId: machine.host.id, + state: "enrolled", + createdAt: 1, + updatedAt: 1, + }) + .run(); + const { project } = seedProjectWithSource(harness.deps, { + hostId: first.host.id, + }); + await updateMachineEnvironment( + harness.db, + harness.config.dataDir, + "CUSTOM_SETUP", + { + name: "CUSTOM_SETUP", + value: "setup-value", + secret: false, + 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 +174,7 @@ describe("project clone sources", () => { ); expect(firstCommand.command).toEqual({ type: "project.clone", + 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 c81277861d..6a5b043ce8 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"; @@ -260,6 +265,74 @@ 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({ + id: "test-machine", + displayName: "Test machine", + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 30_000, + }, + experimental_reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ + status: "created", + 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 () => { await withTestHarness(async (harness) => { registerInstallationProviders( diff --git a/apps/server/test/public/public-terminals.test.ts b/apps/server/test/public/public-terminals.test.ts index cec3988f78..4a015dcf97 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 { machineEnrollments, updateHost } from "@bb/db"; import { createTerminalSession, getTerminalSession, @@ -385,6 +388,78 @@ 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", + secret: true, + }, + ]); + 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", + }); + if (enrolled) + fixture.harness.db + .insert(machineEnrollments) + .values({ + id: "machine", + owner: "do", + key: "machine", + hostId: fixture.host.id, + state: "enrolled", + createdAt: 1, + updatedAt: 1, + }) + .run(); + await updateMachineEnvironment( + fixture.harness.db, + fixture.harness.config.dataDir, + "CUSTOM_TERMINAL", + { + name: "CUSTOM_TERMINAL", + value: "terminal-value", + secret: false, + 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); 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..9e0d86d248 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", diff --git a/apps/server/test/services/environments/provider-orchestration.test.ts b/apps/server/test/services/environments/provider-orchestration.test.ts index 8c8dfb1793..691c94cd83 100644 --- a/apps/server/test/services/environments/provider-orchestration.test.ts +++ b/apps/server/test/services/environments/provider-orchestration.test.ts @@ -1,4 +1,5 @@ import { withEnvironmentCleanupSlot } from "../../../src/services/environments/cleanup-concurrency.js"; +import { machineLifecycles, environmentHookOperations } from "@bb/db"; import { registerTestHostRpcCapture } from "../../helpers/commands.js"; import { reportEnvironmentHookProgress } from "../../../src/services/environments/environment-hooks.js"; import { recordProvisionedEnvironmentWorkspace } from "@bb/db/internal-environment-lifecycle"; @@ -20,6 +21,7 @@ import { claimEnvironmentLaunchPath, createEnvironment, environments, + environmentSetupOutcomes, getEnvironment, getEnvironmentLaunch, getThread, @@ -386,6 +388,18 @@ describe("core environment orchestration", () => { fixture.ask(); await fixture.settled(); expect(fixture.row().phase).toBe("ready"); + const outcome = harness.db + .select() + .from(environmentSetupOutcomes) + .where(eq(environmentSetupOutcomes.hostId, fixture.host.id)) + .get(); + if (ownsPath) + expect(outcome).toMatchObject({ + state: "passed", + path: "/tmp/hooks", + inputHash: expect.any(String), + }); + else expect(outcome).toBeUndefined(); const environmentId = fixture.attach(); await sweepProviderEnvironment(harness.deps, environmentId); expect(getEnvironment(harness.db, environmentId)?.teardownStatus).toBe( @@ -1522,3 +1536,36 @@ describe("core environment orchestration", () => { }), ); }); + +it("records skipped teardown after confirmed filesystem loss and still invokes provider cleanup", async () => + withTestHarness(async (harness) => { + const remove = vi.fn(async () => ({ status: "removed" as const })); + const fixture = setup(harness, { policy: { retireGraceMs: 0 }, remove }); + fixture.ask(); + await fixture.settled(); + const environmentId = fixture.attach(); + harness.db + .insert(machineLifecycles) + .values({ + hostId: fixture.host.id, + observedState: "missing", + observedAt: Date.now(), + recoveryState: "lost-since-last-snapshot", + }) + .run(); + harness.hub.unregisterDaemon(fixture.session.id); + await sweepProviderEnvironment(harness.deps, environmentId); + expect(remove).toHaveBeenCalledOnce(); + expect(getEnvironment(harness.db, environmentId)?.teardownStatus).toBe( + "removed", + ); + const operation = harness.db + .select() + .from(environmentHookOperations) + .where(eq(environmentHookOperations.kind, "teardown")) + .get(); + expect(operation).toMatchObject({ + error: expect.stringContaining("filesystem no longer exists"), + finishedAt: expect.any(Number), + }); + })); 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..f956993a57 --- /dev/null +++ b/apps/server/test/services/machines/provider-orchestration.test.ts @@ -0,0 +1,3653 @@ +import * as gitCredentials from "../../../src/services/machines/git-credentials.js"; +import { + createTerminalSession, + createQueuedThreadMessage, + terminalSessions, + events, + getThread, +} from "@bb/db"; +import { seedTurnStarted } from "../../helpers/seed.js"; +import { machineLifecycles } from "@bb/db"; +import { + assertMachineLifecycleAdmission, + getMachineLifecycle, + machineLifecycleStatus, + observeMachineLifecycle, +} from "../../../src/services/machines/lifecycle.js"; +import { createBbSdk } from "@bb/sdk/core"; +import { createHttpTransport } from "@bb/sdk"; +import { answerMachineReadiness } from "../../helpers/machine-readiness.js"; +import { archiveThreadAndHiddenSourceForks } from "../../../src/services/threads/thread-archive.js"; +import { cancelAbandonedProviderLaunches } from "../../../src/services/threads/thread-environment-providers.js"; +import { serverAccess } from "../../../src/services/machines/server-access.js"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { + createProjectSource, + machineEnrollments, + createEnvironment, + getDefaultProjectSource, + getEnvironment, + getHost, + getMachineLaunch, + listProjectSourcesByProjectIds, + threads, + updateHost, + upsertMachineLaunch, + updateMachineLaunchAttempt, +} from "@bb/db"; +import { hostSchema, type JsonValue, type Project } from "@bb/domain"; +import { createDeferredPromise } from "@bb/test-helpers"; +import { + defineRpcContract, + type PluginMachineProviderDeclaration, +} from "@get-bb/plugin-sdk"; +import { + validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, +} from "@get-bb/plugin-sdk/internal/host-policy"; +import { z } from "zod"; +import { + askMachineLaunch, + submitMachine, + resumeMachine, + cancelMachineLaunch, + createMachine, + prepareMachineProviderSelection, + requestMachineRemoval, + requestMachineResume, + requestMachineSuspension, + resolveThreadMachineLaunchKey, + sweepMachineLifecycles, + 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 { + seedHostSession, + seedProjectWithSource, + seedThread, + seedThreadRuntimeState, +} from "../../helpers/seed.js"; +import { registerTestHostRpcCapture } from "../../helpers/commands.js"; +import { textInput } from "../../helpers/prompt-input.js"; +import { + withTestHarness, + type TestAppHarness, +} from "../../helpers/test-app.js"; +import { sendThreadMessage } from "../../../src/services/threads/thread-send.js"; +import { ensureHostSessionReadyForWork } from "../../../src/services/hosts/host-lifecycle.js"; +import { callPluginHostRpc } from "../../../src/services/plugins/plugin-host-rpc.js"; +import { registerHostRpcResponder } from "../../helpers/host-rpc.js"; +import { stubHostArtifact } from "../../helpers/provider-registry.js"; + +const lifecycleHostContract = defineRpcContract({ + probe: { + input: z.object({}).strict(), + output: z.object({ ok: z.literal(true) }).strict(), + }, +}); + +function installMachineProvider(declaration: PluginMachineProviderDeclaration) { + const record = { + pluginId: "test-machine-plugin", + provider: validatePluginMachineProviderDeclaration(declaration), + }; + 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; +} + +function machineDeclaration( + hostId: string, + overrides: Partial = {}, +): PluginMachineProviderDeclaration { + return { + id: "test-machine", + displayName: "Test machine", + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 10, + }, + experimental_reconcileCleanup: async () => ({ status: "removed" }), + create: async ({ key }) => ({ + status: "created", + hostId, + resource: { key }, + }), + remove: async () => ({ status: "removed" }), + ...overrides, + }; +} + +function adoptMachine( + harness: TestAppHarness, + hostId: string, + resource: JsonValue = { machine: "resource" }, +): void { + updateHost(harness.db, harness.hub, hostId, { + machineProviderId: "test-machine", + machineProviderSelection: { inputs: null }, + phase: "active", + resource, + }); +} + +beforeEach(() => { + vi.spyOn(gitCredentials, "resolveGitCredentials").mockResolvedValue([]); +}); + +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + setPluginMachineProviderBridge(undefined); + setPluginEnvironmentProviderBridge(undefined); +}); + +function seedReadyLaunch( + harness: TestAppHarness, + args: { key: string; hostId: string; projectId?: string }, +) { + upsertMachineLaunch(harness.db, { + key: args.key, + providerId: "test-machine", + projectId: args.projectId ?? null, + inputs: null, + attempt: 1, + phase: "ready", + startedAt: Date.now(), + failedAt: null, + failure: null, + message: null, + transientFailures: 0, + hostId: args.hostId, + resource: { key: args.key }, + stepText: "Ready", + pendingLog: "", + cancelPending: false, + }); +} + +describe("core machine provider orchestration", () => { + it("restarts a persisted create with the same idempotency key after a server crash", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { id: "host_machine" }); + const calls: Array<{ attempt: number; key: string }> = []; + const record = installMachineProvider( + machineDeclaration(host.id, { + create: async ({ attempt, key }) => { + calls.push({ attempt, key }); + return { + status: "created", + hostId: host.id, + resource: { key }, + }; + }, + }), + ); + upsertMachineLaunch(harness.db, { + key: "durable-machine-key", + providerId: record.provider.id, + projectId: null, + inputs: null, + attempt: 4, + phase: "creating", + startedAt: Date.now() - 1_000, + failedAt: null, + failure: null, + message: null, + transientFailures: 0, + hostId: null, + resource: null, + stepText: "Creating Test machine…", + pendingLog: "", + cancelPending: false, + }); + + expect( + askMachineLaunch(harness.deps, { + key: "durable-machine-key", + record, + projectId: null, + inputs: null, + }).action, + ).toBe("wait"); + await expect + .poll(() => getMachineLaunch(harness.db, "durable-machine-key")?.phase) + .toBe("ready"); + expect(calls).toEqual([{ attempt: 4, key: "durable-machine-key" }]); + expect(getHost(harness.db, host.id)).toMatchObject({ + machineProviderId: "test-machine", + resource: { key: "durable-machine-key" }, + }); + })); + + it("parses inputs once and persists the parsed value on the launch and machine", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { id: "host_inputs" }); + const seen: JsonValue[] = []; + installMachineProvider( + machineDeclaration(host.id, { + inputs: z.object({ target: z.string().trim().min(1) }).strict(), + create: async ({ inputs, key }) => { + seen.push(z.object({ target: z.string() }).strict().parse(inputs)); + return { + status: "created", + hostId: host.id, + resource: { key }, + }; + }, + }), + ); + + await createMachine(harness.deps, { + key: "inputs-key", + machineProviderId: "test-machine", + projectId: null, + inputs: { target: " staging " }, + }); + expect(seen).toEqual([{ target: "staging" }]); + expect(getMachineLaunch(harness.db, "inputs-key")?.inputs).toEqual({ + target: "staging", + }); + expect(getHost(harness.db, host.id)?.machineProviderSelection).toEqual({ + inputs: { target: "staging" }, + }); + })); + + it("rejects an unknown machine provider", async () => + withTestHarness(async (harness) => { + await expect( + prepareMachineProviderSelection(harness.deps, { + machineProviderId: "missing-machine", + projectId: null, + inputs: null, + }), + ).rejects.toThrow('Unknown machine provider "missing-machine"'); + })); + + it("allows a standalone machine when project facts only constrain project creation", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host_standalone", + }); + const contexts: Array<{ + project: Project | null; + gitRemote: string | null; + }> = []; + installMachineProvider( + machineDeclaration(host.id, { + requires: { gitRemote: true }, + validate: ({ project, gitRemote }) => { + contexts.push({ project, gitRemote }); + return { action: "accept" }; + }, + }), + ); + + await expect( + prepareMachineProviderSelection(harness.deps, { + machineProviderId: "test-machine", + projectId: null, + inputs: null, + }), + ).resolves.toMatchObject({ inputs: null }); + expect(contexts).toEqual([{ project: null, gitRemote: null }]); + })); + + it("recovers and removes a machine when creation is cancelled", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { id: "host_cancel" }); + const calls: string[] = []; + const record = installMachineProvider( + machineDeclaration(host.id, { + create: ({ key, signal }) => + new Promise((_resolve, reject) => { + calls.push(`create:${key}`); + const row = getMachineLaunch(harness.db, key); + if (row === null) throw new Error("Missing launch"); + updateMachineLaunchAttempt(harness.db, { + ...row, + hostId: host.id, + }); + signal.addEventListener( + "abort", + () => reject(new Error("aborted")), + { once: true }, + ); + }), + experimental_reconcileCleanup: async ({ key }) => { + calls.push(`reconcile:${key}`); + return { status: "removed" }; + }, + }), + ); + + askMachineLaunch(harness.deps, { + key: "cancel-key", + record, + projectId: null, + inputs: null, + }); + await cancelMachineLaunch(harness.deps, "cancel-key"); + + expect(calls).toEqual(["create:cancel-key", "reconcile:cancel-key"]); + expect(getMachineLaunch(harness.db, "cancel-key")).toMatchObject({ + phase: "cancelled", + cancelPending: false, + }); + expect(getHost(harness.db, host.id)).toMatchObject({ + destroyedAt: expect.any(Number), + phase: "destroyed", + }); + })); + + it("removes checkpointed allocations without reconnecting and retries access independently", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(20_000); + const { host } = seedHostSession(harness.deps, { + id: "host_checkpoint_cancel", + }); + const checkpointed = createDeferredPromise(); + const create = vi.fn( + async ( + context: Parameters[0], + ) => { + const row = getMachineLaunch(harness.db, context.key); + if (row === null) throw new Error("Missing launch"); + updateMachineLaunchAttempt(harness.db, { ...row, hostId: host.id }); + await context.checkpoint({ allocation: "vendor-id" }); + checkpointed.resolve(); + await new Promise((_resolve, reject) => { + context.signal.addEventListener( + "abort", + () => reject(new Error("unreachable server")), + { once: true }, + ); + }); + return { + status: "failed" as const, + failure: "terminal" as const, + message: "unreachable", + }; + }, + ); + const remove = vi.fn(async () => ({ status: "removed" as const })); + const record = installMachineProvider( + machineDeclaration(host.id, { create, remove }), + ); + askMachineLaunch(harness.deps, { + key: "checkpoint-cancel", + record, + projectId: null, + inputs: null, + }); + await checkpointed.promise; + const revoke = vi + .spyOn(serverAccess, "release") + .mockRejectedValueOnce(new Error("revocation unavailable")); + await expect( + cancelMachineLaunch(harness.deps, "checkpoint-cancel"), + ).rejects.toThrow("revocation unavailable"); + expect(getMachineLaunch(harness.db, "checkpoint-cancel")).toMatchObject({ + cleanupResourceRemoved: true, + cancelPending: true, + }); + await sweepMachineLifecycles(harness.deps); + expect(revoke).toHaveBeenCalledOnce(); + expect( + getMachineLaunch(harness.db, "checkpoint-cancel")?.cleanupRetryAt, + ).toBe(20_010); + vi.setSystemTime(20_010); + await sweepMachineLifecycles(harness.deps); + expect(revoke).toHaveBeenCalledTimes(2); + expect(create).toHaveBeenCalledOnce(); + expect(remove).toHaveBeenCalledOnce(); + expect(remove).toHaveBeenCalledWith( + expect.objectContaining({ + hostId: host.id, + resource: { allocation: "vendor-id" }, + }), + ); + expect(getHost(harness.db, host.id)?.phase).toBe("destroyed"); + revoke.mockRestore(); + })); + + it.each(["normal", "cancelled", "recovery"])( + "rejects a reserved-host mismatch during %s creation and cleans only the checkpoint", + async (scenario) => + withTestHarness(async (harness) => { + const { host: reserved } = seedHostSession(harness.deps, { + id: "host_reserved", + }); + const { host: foreign } = seedHostSession(harness.deps, { + id: "host_foreign", + }); + const foreignBefore = getHost(harness.db, foreign.id); + const key = `host-mismatch-${scenario}`; + const resource = { allocation: "reserved-allocation" }; + const checkpointed = createDeferredPromise(); + const complete = createDeferredPromise(); + const create = vi.fn( + async ( + context: Parameters[0], + ) => { + const row = getMachineLaunch(harness.db, context.key); + if (row === null) throw new Error("Missing launch"); + updateMachineLaunchAttempt(harness.db, { + ...row, + hostId: reserved.id, + }); + await context.checkpoint(resource); + checkpointed.resolve(); + if (scenario === "cancelled") await complete.promise; + return { + status: "created" as const, + hostId: foreign.id, + resource: { allocation: "foreign-allocation" }, + }; + }, + ); + const remove = vi.fn(async () => { + expect(getMachineLaunch(harness.db, key)).toMatchObject({ + hostId: reserved.id, + resource, + }); + return { status: "removed" as const }; + }); + const record = installMachineProvider( + machineDeclaration(foreign.id, { create, remove }), + ); + const release = vi.spyOn(serverAccess, "release"); + const revokeEnroll = vi.spyOn( + harness.deps.machineAuth, + "revokeHostEnrollKeys", + ); + const revokeAuth = vi.spyOn( + harness.deps.machineAuth, + "revokeHostAuthKeys", + ); + try { + if (scenario === "recovery") { + seedReadyLaunch(harness, { key, hostId: reserved.id }); + const row = getMachineLaunch(harness.db, key); + if (row === null) throw new Error("Missing launch"); + updateMachineLaunchAttempt(harness.db, { + ...row, + phase: "cancelled", + cancelPending: true, + resource, + }); + await cancelMachineLaunch(harness.deps, key); + } else { + askMachineLaunch(harness.deps, { + key, + record, + projectId: null, + inputs: null, + }); + await checkpointed.promise; + if (scenario === "normal") { + await vi.waitFor(() => { + expect(getMachineLaunch(harness.db, key)).toMatchObject({ + phase: "failed", + failure: "terminal", + hostId: reserved.id, + message: expect.stringContaining("instead of reserved host"), + }); + }); + } + const cancellation = cancelMachineLaunch(harness.deps, key); + complete.resolve(); + await cancellation; + } + expect(create).toHaveBeenCalledTimes(scenario === "recovery" ? 0 : 1); + expect(remove).toHaveBeenCalledExactlyOnceWith( + expect.objectContaining({ hostId: reserved.id, resource }), + ); + expect(release).toHaveBeenCalledExactlyOnceWith(harness.deps, { + key, + hostId: reserved.id, + }); + expect(revokeEnroll).toHaveBeenCalledExactlyOnceWith({ + hostId: reserved.id, + }); + expect(revokeAuth).toHaveBeenCalledExactlyOnceWith({ + hostId: reserved.id, + }); + expect(getMachineLaunch(harness.db, key)).toMatchObject({ + hostId: reserved.id, + cancelPending: false, + cleanupResourceRemoved: true, + }); + expect(getHost(harness.db, reserved.id)?.phase).toBe("destroyed"); + expect(getHost(harness.db, foreign.id)).toEqual(foreignBefore); + } finally { + release.mockRestore(); + revokeEnroll.mockRestore(); + revokeAuth.mockRestore(); + } + }), + ); + + it("finalizes host cleanup when creation succeeds after cancellation", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host_late_cancel", + }); + const remove = vi.fn(async () => ({ status: "removed" as const })); + const record = installMachineProvider( + machineDeclaration(host.id, { + create: ({ signal }) => + new Promise((resolve) => { + signal.addEventListener( + "abort", + () => + resolve({ + status: "created", + hostId: host.id, + resource: { allocation: "late" }, + }), + { once: true }, + ); + }), + remove, + }), + ); + askMachineLaunch(harness.deps, { + key: "late-cancel", + record, + projectId: null, + inputs: null, + }); + await cancelMachineLaunch(harness.deps, "late-cancel"); + expect(remove).toHaveBeenCalledOnce(); + expect(getHost(harness.db, host.id)?.phase).toBe("destroyed"); + expect(getMachineLaunch(harness.db, "late-cancel")).toMatchObject({ + cancelPending: false, + cleanupResourceRemoved: true, + }); + })); + + it.each(["terminal", "exhausted", "retry"])( + "retains checkpoints through %s failure handling", + async (scenario) => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: `host_failure_${scenario}`, + }); + const resource = { allocation: `allocated-${scenario}` }; + const create = vi.fn(async () => ({ + status: "failed" as const, + failure: "terminal" as const, + message: "access unavailable", + })); + const remove = vi.fn(async () => ({ status: "removed" as const })); + const record = installMachineProvider( + machineDeclaration(host.id, { create, remove }), + ); + const key = `failure-${scenario}`; + upsertMachineLaunch(harness.db, { + key, + providerId: record.provider.id, + projectId: null, + inputs: null, + attempt: 1, + phase: "failed", + startedAt: Date.now() - 60000, + failedAt: Date.now() - 60000, + failure: scenario === "terminal" ? "terminal" : "transient", + message: "bootstrap failed", + transientFailures: scenario === "exhausted" ? 4 : 1, + hostId: host.id, + resource, + stepText: "Bootstrap failed", + pendingLog: "", + cancelPending: false, + }); + if (scenario === "retry") { + askMachineLaunch(harness.deps, { + key, + record, + projectId: null, + inputs: null, + }); + expect(getMachineLaunch(harness.db, key)).toMatchObject({ + hostId: host.id, + resource, + attempt: 2, + }); + await cancelMachineLaunch(harness.deps, key); + expect(create).toHaveBeenCalledOnce(); + } else { + await sweepMachineLifecycles(harness.deps); + expect(create).not.toHaveBeenCalled(); + } + expect(remove).toHaveBeenCalledWith( + expect.objectContaining({ hostId: host.id, resource }), + ); + expect(getHost(harness.db, host.id)?.phase).toBe("destroyed"); + }), + ); + + it("keeps cancellation pending after a transient recovery failure and retries after the retry deadline", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(20_000); + const { host } = seedHostSession(harness.deps, { + id: "host_cancel_retry", + }); + const calls: string[] = []; + let attempts = 0; + const record = installMachineProvider( + machineDeclaration(host.id, { + experimental_reconcileCleanup: async () => { + attempts += 1; + calls.push(`reconcile:${attempts}`); + return attempts === 1 + ? { + status: "failed", + message: "Modal lookup timed out", + } + : { + status: "removed", + }; + }, + remove: async () => { + calls.push("remove"); + return { status: "removed" }; + }, + }), + ); + upsertMachineLaunch(harness.db, { + key: "cancel-retry-key", + providerId: record.provider.id, + projectId: null, + inputs: null, + attempt: 1, + phase: "cancelled", + startedAt: Date.now(), + failedAt: null, + failure: null, + message: null, + transientFailures: 0, + hostId: null, + resource: null, + stepText: "Cancelling Test machine…", + pendingLog: "", + cancelPending: true, + }); + + await sweepMachineLifecycles(harness.deps); + expect(calls).toEqual(["reconcile:1"]); + expect(getMachineLaunch(harness.db, "cancel-retry-key")).toMatchObject({ + phase: "cancelled", + cancelPending: true, + }); + + await sweepMachineLifecycles(harness.deps); + expect(calls).toEqual(["reconcile:1"]); + vi.setSystemTime(20_010); + await sweepMachineLifecycles(harness.deps); + expect(calls).toEqual(["reconcile:1", "reconcile:2"]); + expect(getMachineLaunch(harness.db, "cancel-retry-key")).toMatchObject({ + phase: "cancelled", + cancelPending: false, + }); + })); + + it("rejects reuse of an API key whose ready machine was destroyed", async () => + withTestHarness(async (harness) => { + const { host: destroyedHost } = seedHostSession(harness.deps, { + id: "host_destroyed_launch", + }); + const { host: replacementHost } = seedHostSession(harness.deps, { + id: "host_replacement_launch", + }); + updateHost(harness.db, harness.hub, destroyedHost.id, { + destroyedAt: Date.now(), + phase: "destroyed", + }); + const calls: number[] = []; + const record = installMachineProvider( + machineDeclaration(replacementHost.id, { + create: async ({ attempt, key }) => { + calls.push(attempt); + return { + status: "created", + hostId: replacementHost.id, + resource: { key }, + }; + }, + }), + ); + upsertMachineLaunch(harness.db, { + key: "ready-destroyed-key", + providerId: record.provider.id, + projectId: null, + inputs: null, + attempt: 1, + phase: "ready", + startedAt: Date.now() - 1_000, + failedAt: null, + failure: null, + message: null, + transientFailures: 0, + hostId: destroyedHost.id, + resource: { key: "ready-destroyed-key" }, + stepText: "Ready", + pendingLog: "", + cancelPending: false, + }); + + expect( + askMachineLaunch(harness.deps, { + key: "ready-destroyed-key", + record, + projectId: null, + inputs: null, + }).action, + ).toBe("reject"); + await expect( + createMachine(harness.deps, { + key: "ready-destroyed-key", + machineProviderId: record.provider.id, + projectId: null, + inputs: null, + }), + ).rejects.toMatchObject({ + status: 409, + message: expect.stringContaining("destroyed machine"), + }); + expect(calls).toEqual([]); + expect(getMachineLaunch(harness.db, "ready-destroyed-key")).toMatchObject( + { + phase: "ready", + attempt: 1, + hostId: destroyedHost.id, + resource: { key: "ready-destroyed-key" }, + }, + ); + })); + + it("walks destroyed generations and keeps a replacement key stable across transient retries", async () => + withTestHarness(async (harness) => { + const first = seedHostSession(harness.deps, { + id: "generation-first", + }).host; + const second = seedHostSession(harness.deps, { + id: "generation-second", + }).host; + const third = seedHostSession(harness.deps, { + id: "generation-third", + }).host; + const base = "thread-generations"; + seedReadyLaunch(harness, { key: base, hostId: first.id }); + updateHost(harness.db, harness.hub, first.id, { + destroyedAt: Date.now(), + phase: "destroyed", + }); + const replacementKey = `${base}:replacement:${first.id}`; + expect(resolveThreadMachineLaunchKey(harness.deps, base)).toBe( + replacementKey, + ); + const create = vi.fn( + async ({ key, attempt }: { key: string; attempt: number }) => + attempt === 1 + ? { + status: "failed" as const, + failure: "transient" as const, + message: "try again", + } + : { + status: "created" as const, + hostId: second.id, + resource: { key }, + }, + ); + const record = installMachineProvider( + machineDeclaration(second.id, { create }), + ); + const request = { + record, + key: replacementKey, + projectId: null, + inputs: null, + }; + expect(askMachineLaunch(harness.deps, request).action).toBe("wait"); + await vi.waitFor(() => + expect(getMachineLaunch(harness.db, replacementKey)?.phase).toBe( + "failed", + ), + ); + expect(resolveThreadMachineLaunchKey(harness.deps, base)).toBe( + replacementKey, + ); + updateMachineLaunchAttempt(harness.db, { + key: replacementKey, + attempt: 1, + failedAt: Date.now() - 30_001, + }); + expect(askMachineLaunch(harness.deps, request).action).toBe("wait"); + await vi.waitFor(() => + expect(getMachineLaunch(harness.db, replacementKey)?.phase).toBe( + "ready", + ), + ); + expect( + create.mock.calls.map(([request]) => [request.key, request.attempt]), + ).toEqual([ + [replacementKey, 1], + [replacementKey, 2], + ]); + expect(getMachineLaunch(harness.db, base)).toMatchObject({ + attempt: 1, + phase: "ready", + hostId: first.id, + }); + expect(resolveThreadMachineLaunchKey(harness.deps, base)).toBe( + replacementKey, + ); + updateHost(harness.db, harness.hub, second.id, { + destroyedAt: Date.now(), + phase: "destroyed", + }); + const nextKey = `${base}:replacement:${second.id}`; + expect(resolveThreadMachineLaunchKey(harness.deps, base)).toBe(nextKey); + seedReadyLaunch(harness, { key: nextKey, hostId: third.id }); + expect(resolveThreadMachineLaunchKey(harness.deps, base)).toBe(nextKey); + })); + + it("keeps late cleanup from an old attempt on its old host and resource", async () => + withTestHarness(async (harness) => { + const oldHost = seedHostSession(harness.deps, { + id: "generation-old-late", + }).host; + const newHost = seedHostSession(harness.deps, { + id: "generation-new-live", + }).host; + const oldResult = createDeferredPromise<{ + status: "created"; + hostId: string; + resource: { key: string }; + }>(); + const remove = vi.fn(async () => ({ status: "removed" as const })); + const base = "thread-late-generation"; + const record = installMachineProvider( + machineDeclaration(newHost.id, { + create: ({ key }) => + key === base + ? oldResult.promise + : Promise.resolve({ + status: "created", + hostId: newHost.id, + resource: { key }, + }), + remove, + }), + ); + askMachineLaunch(harness.deps, { + key: base, + record, + projectId: null, + inputs: null, + }); + seedReadyLaunch(harness, { key: base, hostId: oldHost.id }); + updateHost(harness.db, harness.hub, oldHost.id, { + destroyedAt: Date.now(), + phase: "destroyed", + }); + const key = resolveThreadMachineLaunchKey(harness.deps, base); + askMachineLaunch(harness.deps, { + key, + record, + projectId: null, + inputs: null, + }); + await vi.waitFor(() => + expect(getMachineLaunch(harness.db, key)?.phase).toBe("ready"), + ); + oldResult.resolve({ + status: "created", + hostId: oldHost.id, + resource: { key: base }, + }); + await vi.waitFor(() => expect(remove).toHaveBeenCalledOnce()); + expect(remove).toHaveBeenCalledWith( + expect.objectContaining({ + hostId: oldHost.id, + resource: { key: base }, + }), + ); + await cancelMachineLaunch(harness.deps, base); + expect(getHost(harness.db, newHost.id)).toMatchObject({ + destroyedAt: null, + phase: "active", + resource: { key }, + }); + expect(getMachineLaunch(harness.db, key)).toMatchObject({ + phase: "ready", + hostId: newHost.id, + resource: { key }, + }); + })); + + it.each(["archive", "abandon"] as const)( + "cancels only the current generation on thread %s", + async (action) => + withTestHarness(async (harness) => { + const oldHost = seedHostSession(harness.deps, { + id: "generation-cancel-old", + }).host; + const newHost = seedHostSession(harness.deps, { + id: "generation-cancel-new", + }).host; + const { project } = seedProjectWithSource(harness.deps, { + hostId: oldHost.id, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: null, + status: "starting", + }); + seedReadyLaunch(harness, { + key: thread.id, + hostId: oldHost.id, + projectId: project.id, + }); + updateHost(harness.db, harness.hub, oldHost.id, { + destroyedAt: Date.now(), + phase: "destroyed", + }); + const key = resolveThreadMachineLaunchKey(harness.deps, thread.id); + const remove = vi.fn(async () => ({ status: "removed" as const })); + const create = vi.fn( + ({ signal, key }: { signal: AbortSignal; key: string }) => + new Promise<{ + status: "created"; + hostId: string; + resource: { key: string }; + }>((resolve) => { + signal.addEventListener( + "abort", + () => + resolve({ + status: "created", + hostId: newHost.id, + resource: { key }, + }), + { once: true }, + ); + }), + ); + const record = installMachineProvider( + machineDeclaration(newHost.id, { create, remove }), + ); + askMachineLaunch(harness.deps, { + key, + record, + projectId: project.id, + inputs: null, + }); + await vi.waitFor(() => expect(create).toHaveBeenCalledOnce()); + if (action === "archive") + archiveThreadAndHiddenSourceForks(harness.deps, { + thread, + environment: null, + }); + else cancelAbandonedProviderLaunches(harness.deps, thread.id); + await vi.waitFor(() => + expect(getMachineLaunch(harness.db, key)).toMatchObject({ + phase: "cancelled", + cancelPending: false, + }), + ); + expect(remove).toHaveBeenCalledWith( + expect.objectContaining({ hostId: newHost.id, resource: { key } }), + ); + expect(getMachineLaunch(harness.db, thread.id)).toMatchObject({ + phase: "ready", + hostId: oldHost.id, + }); + expect(getHost(harness.db, newHost.id)?.phase).toBe("destroyed"); + expect(create).toHaveBeenCalledOnce(); + }), + ); + + it("cancels a pending machine creation when its thread is deleted", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host_deleted_launch", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/deleted-machine-launch", + }); + const thread = seedThread(harness.deps, { + environmentId: null, + projectId: project.id, + status: "starting", + }); + const calls: string[] = []; + const record = installMachineProvider( + machineDeclaration(host.id, { + create: ({ key, signal }) => + new Promise((_resolve, reject) => { + calls.push(`create:${key}`); + const row = getMachineLaunch(harness.db, key); + if (row === null) throw new Error("Missing launch"); + updateMachineLaunchAttempt(harness.db, { + ...row, + hostId: host.id, + }); + signal.addEventListener( + "abort", + () => reject(new Error("aborted")), + { once: true }, + ); + }), + experimental_reconcileCleanup: async ({ key }) => { + calls.push(`reconcile:${key}`); + return { status: "removed" }; + }, + }), + ); + askMachineLaunch(harness.deps, { + key: thread.id, + record, + projectId: project.id, + inputs: null, + }); + await vi.waitFor(() => { + expect(calls).toEqual([`create:${thread.id}`]); + }); + + const response = await harness.app.request( + `/api/v1/threads/${thread.id}`, + { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ childThreadsConfirmed: false }), + }, + ); + expect(response.status).toBe(200); + await vi.waitFor(() => { + expect(calls).toEqual([ + `create:${thread.id}`, + `reconcile:${thread.id}`, + ]); + }); + await vi.waitFor(() => { + expect(getMachineLaunch(harness.db, thread.id)).toMatchObject({ + phase: "cancelled", + cancelPending: false, + }); + expect(getHost(harness.db, host.id)).toMatchObject({ + destroyedAt: expect.any(Number), + phase: "destroyed", + }); + }); + })); + + it("starts a durable idle baseline for a box with zero threads and honors its per-machine override", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host } = seedHostSession(harness.deps, { id: "host_empty_idle" }); + const suspend = vi.fn(async ({ resource }: { resource: JsonValue }) => ({ + resource, + })); + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 10, + }, + experimental_idleSuspendMs: async () => 5_000, + suspend, + resume: async ({ resource }) => ({ resource }), + }), + ); + adoptMachine(harness, host.id); + await sweepProviderMachine(harness.deps, host.id); + expect(getHost(harness.db, host.id)?.idleSince).toBe(10_000); + expect(suspend).not.toHaveBeenCalled(); + vi.setSystemTime(14_999); + await sweepProviderMachine(harness.deps, host.id); + expect(suspend).not.toHaveBeenCalled(); + vi.setSystemTime(15_000); + await sweepProviderMachine(harness.deps, host.id); + expect(suspend).toHaveBeenCalledOnce(); + expect(getHost(harness.db, host.id)?.phase).toBe("suspended"); + })); + + it("suspends after every live thread has been idle for the policy delay", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host } = seedHostSession(harness.deps, { id: "host_suspend" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/suspend", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/suspend", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ updatedAt: 1_000 }) + .where(eq(threads.id, thread.id)) + .run(); + let suspends = 0; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 5_000, + retire: { after: "never" }, + removeRetryMs: 10, + }, + suspend: async () => { + suspends += 1; + return { resource: { snapshot: "snap-1" } }; + }, + resume: async ({ resource }) => ({ resource }), + }), + ); + adoptMachine(harness, host.id); + + await sweepProviderMachine(harness.deps, host.id); + expect(suspends).toBe(1); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "suspended", + suspendedAt: 10_000, + resource: { snapshot: "snap-1" }, + }); + })); + + it("allows a suspend callback to call its own host RPC", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host, session } = seedHostSession(harness.deps, { + id: "host_suspend_rpc", + }); + const responder = registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: (request) => { + if (request.command.type !== "plugin.host.call") { + throw new Error(`Unexpected RPC ${request.command.type}`); + } + return { ok: true, result: { output: { ok: true } } }; + }, + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/suspend-rpc", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/suspend-rpc", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ updatedAt: 1_000 }) + .where(eq(threads.id, thread.id)) + .run(); + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 5_000, + retire: { after: "never" }, + removeRetryMs: 10, + }, + suspend: async ({ hostId, resource, signal }) => { + await callPluginHostRpc(harness.deps, { + pluginId: "test-machine-plugin", + contract: lifecycleHostContract, + method: "probe", + input: {}, + hostId, + signal, + artifact: stubHostArtifact("test-machine-plugin"), + }); + return { resource }; + }, + resume: async ({ resource }) => ({ resource }), + }), + ); + adoptMachine(harness, host.id); + + const sweep = sweepProviderMachine(harness.deps, host.id); + const outcome = await Promise.race([ + sweep.then(() => "completed" as const), + new Promise<"blocked">((resolve) => + setImmediate(() => resolve("blocked")), + ), + ]); + + expect(outcome).toBe("completed"); + expect(responder.requests).toHaveLength(1); + })); + + it("persists a suspension checkpoint even when the provider crashes afterward", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host, session } = seedHostSession(harness.deps, { + id: "host_suspend_checkpoint", + }); + const socket = registerTestHostRpcCapture(harness.deps, { + hostId: host.id, + sessionId: session.id, + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/suspend-checkpoint", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/suspend-checkpoint", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ updatedAt: 1_000 }) + .where(eq(threads.id, thread.id)) + .run(); + let resumes = 0; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 5_000, + retire: { after: "never" }, + removeRetryMs: 10, + }, + suspend: async (context) => { + context.checkpoint({ snapshot: "snap-recoverable" }); + harness.hub.unregisterDaemon(session.id); + throw new Error("server crashed after checkpoint"); + }, + resume: async ({ resource }) => { + resumes += 1; + harness.hub.registerDaemon(session.id, host.id, socket); + const checkpoint = z + .object({ snapshot: z.string() }) + .strict() + .parse(resource); + return { resource: { ...checkpoint, recovered: true } }; + }, + }), + ); + adoptMachine(harness, host.id, { sandbox: "live" }); + + await expect(sweepProviderMachine(harness.deps, host.id)).rejects.toThrow( + "server crashed after checkpoint", + ); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "suspending", + resource: { snapshot: "snap-recoverable" }, + }); + harness.db + .update(threads) + .set({ status: "error", updatedAt: 10_001 }) + .where(eq(threads.id, thread.id)) + .run(); + + await expect( + ensureHostSessionReadyForWork(harness.deps, { hostId: host.id }), + ).resolves.toMatchObject({ hostId: host.id }); + expect(resumes).toBe(1); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "active", + resource: { snapshot: "snap-recoverable", recovered: true }, + }); + updateHost(harness.db, harness.hub, host.id, { + phase: "suspending", + resource: { snapshot: "snap-sweep-recovery" }, + }); + harness.hub.unregisterDaemon(session.id); + + await sweepProviderMachine(harness.deps, host.id); + expect(resumes).toBe(2); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "active", + resource: { snapshot: "snap-sweep-recovery", recovered: true }, + }); + })); + + it("recovers a persisted suspending machine from its surviving resource", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host_surviving_suspend", + }); + const resources: JsonValue[] = []; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 1, + retire: { after: "never" }, + removeRetryMs: 10, + }, + suspend: async ({ resource }) => ({ resource }), + resume: async ({ resource }) => { + resources.push(resource); + return { resource: { sandbox: "surviving", resumed: true } }; + }, + }), + ); + adoptMachine(harness, host.id, { + sandbox: "surviving", + snapshot: null, + }); + updateHost(harness.db, harness.hub, host.id, { + phase: "suspending", + }); + + await sweepProviderMachine(harness.deps, host.id); + + expect(resources).toEqual([{ sandbox: "surviving", snapshot: null }]); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "active", + resource: { sandbox: "surviving", resumed: true }, + }); + })); + + it("serializes removal after an in-flight suspension", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host } = seedHostSession(harness.deps, { + id: "host_suspend_remove", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/suspend-remove", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/suspend-remove", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ updatedAt: 1_000 }) + .where(eq(threads.id, thread.id)) + .run(); + const suspendStarted = createDeferredPromise(); + const suspendRelease = createDeferredPromise(); + const removeStarted = createDeferredPromise(); + const removedResources: JsonValue[] = []; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 5_000, + retire: { after: "never" }, + removeRetryMs: 10, + }, + suspend: async () => { + suspendStarted.resolve(); + await suspendRelease.promise; + return { resource: { snapshot: "snap-before-remove" } }; + }, + resume: async ({ resource }) => ({ resource }), + remove: async ({ resource }) => { + removedResources.push(resource); + removeStarted.resolve(); + return { status: "removed" }; + }, + }), + ); + adoptMachine(harness, host.id, { sandbox: "live" }); + + const suspendSweep = sweepProviderMachine(harness.deps, host.id); + await suspendStarted.promise; + harness.db + .update(threads) + .set({ archivedAt: 10_000 }) + .where(eq(threads.id, thread.id)) + .run(); + expect(requestMachineRemoval(harness.deps, host.id)).toBe(true); + const removalSweep = sweepProviderMachine(harness.deps, host.id); + const order = await Promise.race([ + removeStarted.promise.then(() => "removed" as const), + new Promise<"waiting">((resolve) => + setImmediate(() => resolve("waiting")), + ), + ]); + suspendRelease.resolve(); + await Promise.all([suspendSweep, removalSweep]); + + expect(order).toBe("waiting"); + expect(removedResources).toEqual([{ snapshot: "snap-before-remove" }]); + expect(getHost(harness.db, host.id)).toMatchObject({ + destroyedAt: expect.any(Number), + phase: "destroyed", + resource: null, + teardownStatus: "removed", + }); + })); + + it("ignores a suspension result that finishes after the machine was destroyed", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host } = seedHostSession(harness.deps, { + id: "host_stale_suspend_completion", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/stale-suspend-completion", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/stale-suspend-completion", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ updatedAt: 1_000 }) + .where(eq(threads.id, thread.id)) + .run(); + const suspendStarted = createDeferredPromise(); + const suspendRelease = createDeferredPromise(); + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 5_000, + retire: { after: "never" }, + removeRetryMs: 10, + }, + suspend: async () => { + suspendStarted.resolve(); + await suspendRelease.promise; + return { resource: { snapshot: "stale-snapshot" } }; + }, + resume: async ({ resource }) => ({ resource }), + }), + ); + adoptMachine(harness, host.id, { sandbox: "live" }); + + const suspendSweep = sweepProviderMachine(harness.deps, host.id); + await suspendStarted.promise; + updateHost(harness.db, harness.hub, host.id, { + destroyedAt: 10_000, + phase: "destroyed", + resource: null, + suspendedAt: null, + teardownStatus: "removed", + }); + suspendRelease.resolve(); + await suspendSweep; + + expect(getHost(harness.db, host.id)).toMatchObject({ + destroyedAt: 10_000, + phase: "destroyed", + resource: null, + suspendedAt: null, + teardownStatus: "removed", + }); + })); + + it("scheduled resume is idempotent when active and waits for manual snapshot suspension", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host_scheduled_wake", + }); + const entered = createDeferredPromise(); + const finish = createDeferredPromise(); + let resumes = 0; + installMachineProvider( + machineDeclaration(host.id, { + suspend: async () => { + entered.resolve(); + await finish.promise; + return { resource: { snapshot: "fresh" } }; + }, + resume: async () => { + resumes += 1; + return { resource: { snapshot: "fresh" } }; + }, + }), + ); + adoptMachine(harness, host.id, { sandbox: "live" }); + await requestMachineResume(harness.deps, host.id); + expect(resumes).toBe(0); + const sleeping = requestMachineSuspension(harness.deps, host.id); + await entered.promise; + let completed = false; + const waking = requestMachineResume(harness.deps, host.id).then(() => { + completed = true; + }); + await Promise.resolve(); + expect(completed).toBe(false); + expect(resumes).toBe(0); + finish.resolve(); + await Promise.all([sleeping, waking]); + expect(resumes).toBe(1); + expect(getHost(harness.db, host.id)?.phase).toBe("active"); + await requestMachineResume(harness.deps, host.id); + expect(resumes).toBe(1); + })); + + it("waits for an in-flight suspension and resumes before dispatching new work", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host, session } = seedHostSession(harness.deps, { + id: "host_suspend_race", + }); + registerTestHostRpcCapture(harness.deps, { + hostId: host.id, + sessionId: session.id, + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/suspend-race", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/suspend-race", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ updatedAt: 1_000 }) + .where(eq(threads.id, thread.id)) + .run(); + const suspendStarted = createDeferredPromise(); + const suspendRelease = createDeferredPromise(); + let resumes = 0; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 5_000, + retire: { after: "never" }, + removeRetryMs: 10, + }, + suspend: async () => { + suspendStarted.resolve(); + await suspendRelease.promise; + return { resource: { snapshot: "snap-race" } }; + }, + resume: async () => { + resumes += 1; + return { resource: { sandbox: "resumed" } }; + }, + }), + ); + adoptMachine(harness, host.id, { sandbox: "live" }); + + const sweep = sweepProviderMachine(harness.deps, host.id); + await suspendStarted.promise; + let dispatched = false; + const admission = ensureHostSessionReadyForWork(harness.deps, { + hostId: host.id, + }).then(() => { + dispatched = true; + }); + await Promise.resolve(); + expect(dispatched).toBe(false); + suspendRelease.resolve(); + + await Promise.all([sweep, admission]); + expect(resumes).toBe(1); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "active", + suspendedAt: null, + resource: { sandbox: "resumed" }, + }); + })); + + it("resumes a suspended provider machine when a message is sent", async () => + withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host_resume", + }); + const socket = registerTestHostRpcCapture(harness.deps, { + hostId: host.id, + sessionId: session.id, + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/resume", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/resume", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + seedThreadRuntimeState(harness.deps, { + environmentId: environment.id, + providerThreadId: "provider-resume", + threadId: thread.id, + }); + let resumes = 0; + let observedProgress: string | null = null; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 1, + retire: { after: "never" }, + removeRetryMs: 10, + }, + suspend: async ({ resource }) => ({ resource }), + resume: async ({ report }) => { + resumes += 1; + report.step("Restoring the test machine…"); + const hosts = hostSchema + .array() + .parse(await (await harness.app.request("/api/v1/hosts")).json()); + observedProgress = + hosts.find((candidate) => candidate.id === host.id)?.lifecycle + .progress ?? null; + harness.hub.registerDaemon(session.id, host.id, socket); + return { resource: { sandbox: "resumed" } }; + }, + }), + ); + adoptMachine(harness, host.id, { snapshot: "snap-1" }); + updateHost(harness.db, harness.hub, host.id, { + phase: "suspended", + suspendedAt: Date.now(), + }); + harness.hub.unregisterDaemon(session.id); + + const readiness = answerMachineReadiness(harness); + await expect( + sendThreadMessage(harness.deps, { + environment, + payload: { + input: textInput("resume this machine"), + mode: "start", + model: "gpt-5", + permissionMode: "full", + reasoningLevel: "medium", + serviceTier: "default", + }, + thread, + trigger: "user", + }), + ).resolves.toBeUndefined(); + await readiness; + expect(resumes).toBe(1); + expect(observedProgress).toBe("Restoring the test machine…"); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "active", + suspendedAt: null, + resource: { sandbox: "resumed" }, + }); + })); + + it("retires after the last thread and cascades environment removal first", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(20_000); + const { host } = seedHostSession(harness.deps, { id: "host_retire" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/retire", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/retire", + providerOwnsPath: true, + status: "ready", + environmentProvider: { + pluginId: "test-environment-plugin", + environmentProviderId: "test-environment", + instanceKey: "retire-environment", + selection: { + machine: { type: "existing", hostId: host.id }, + inputs: null, + }, + }, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ archivedAt: 20_000 }) + .where(eq(threads.id, thread.id)) + .run(); + const order: string[] = []; + const environmentProvider = validatePluginEnvironmentProviderDeclaration({ + id: "test-environment", + displayName: "Test environment", + create: async () => ({ + status: "created", + path: "/tmp/retire", + ownsPath: true, + }), + remove: async () => { + order.push("environment"); + return { status: "removed" }; + }, + }); + setPluginEnvironmentProviderBridge({ + listEnvironmentProviders: () => [ + { + pluginId: "test-environment-plugin", + provider: environmentProvider, + }, + ], + getEnvironmentProvider: (id) => + id === environmentProvider.id + ? { + pluginId: "test-environment-plugin", + provider: environmentProvider, + } + : undefined, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: null, + retire: { after: "last-thread", graceMs: 5_000 }, + removeRetryMs: 10, + }, + remove: async () => { + order.push("machine"); + return { status: "removed" }; + }, + }), + ); + adoptMachine(harness, host.id); + + await sweepProviderMachine(harness.deps, host.id); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "retiring", + retireAt: 25_000, + }); + expect(order).toEqual([]); + vi.setSystemTime(25_001); + await sweepProviderMachine(harness.deps, host.id); + expect(order).toEqual(["environment", "machine"]); + expect(getEnvironment(harness.db, environment.id)).toMatchObject({ + status: "destroyed", + teardownStatus: "removed", + }); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "destroyed", + teardownStatus: "removed", + }); + })); + + it("resumes a suspended retiring machine for its environment cascade without clearing retirement", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(20_000); + const { host, session } = seedHostSession(harness.deps, { + id: "host_suspended_retire", + }); + const socket = registerTestHostRpcCapture(harness.deps, { + hostId: host.id, + sessionId: session.id, + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/suspended-retire", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/suspended-retire", + providerOwnsPath: true, + status: "ready", + environmentProvider: { + pluginId: "test-environment-plugin", + environmentProviderId: "test-environment", + instanceKey: "suspended-retire-environment", + selection: { + machine: { type: "existing", hostId: host.id }, + inputs: null, + }, + }, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ archivedAt: 20_000 }) + .where(eq(threads.id, thread.id)) + .run(); + let environmentRemovalPhase: string | null = null; + const environmentProvider = validatePluginEnvironmentProviderDeclaration({ + id: "test-environment", + displayName: "Test environment", + create: async () => ({ + status: "created", + path: "/tmp/suspended-retire", + ownsPath: true, + }), + remove: async () => { + environmentRemovalPhase = getHost(harness.db, host.id)?.phase ?? null; + return harness.hub.hasDaemonForHost(host.id) + ? { status: "removed" } + : { status: "failed", message: "Host is not connected" }; + }, + }); + setPluginEnvironmentProviderBridge({ + listEnvironmentProviders: () => [ + { + pluginId: "test-environment-plugin", + provider: environmentProvider, + }, + ], + getEnvironmentProvider: (id) => + id === environmentProvider.id + ? { + pluginId: "test-environment-plugin", + provider: environmentProvider, + } + : undefined, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + let removes = 0; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 1, + retire: { after: "last-thread", graceMs: 0 }, + removeRetryMs: 10, + }, + suspend: async ({ resource }) => ({ resource }), + resume: async () => { + harness.hub.registerDaemon(session.id, host.id, socket); + return { + resource: { snapshot: "snap-retire", resumed: true }, + }; + }, + remove: async () => { + removes += 1; + return { status: "removed" }; + }, + }), + ); + adoptMachine(harness, host.id, { snapshot: "snap-retire" }); + updateHost(harness.db, harness.hub, host.id, { + phase: "suspended", + suspendedAt: 19_000, + }); + harness.hub.unregisterDaemon(session.id); + + await sweepProviderMachine(harness.deps, host.id); + expect(environmentRemovalPhase).toBe("retiring"); + expect(getEnvironment(harness.db, environment.id)).toMatchObject({ + status: "destroyed", + teardownStatus: "removed", + }); + expect(removes).toBe(1); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "destroyed", + teardownStatus: "removed", + }); + })); + + it("removes a suspended retiring machine directly when no environment needs cleanup", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(20_000); + const { host, session } = seedHostSession(harness.deps, { + id: "host_suspended_retire_without_environment", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/retire-without-host-cleanup", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/retire-without-host-cleanup", + providerOwnsPath: false, + status: "ready", + environmentProvider: { + pluginId: "test-attached-environment-plugin", + environmentProviderId: "test-attached-environment", + instanceKey: "attached-environment", + selection: { + machine: { type: "existing", hostId: host.id }, + inputs: null, + }, + }, + }); + let environmentRemoves = 0; + const environmentProvider = validatePluginEnvironmentProviderDeclaration({ + id: "test-attached-environment", + displayName: "Test attached environment", + create: async () => ({ + status: "created", + path: "/tmp/retire-without-host-cleanup", + ownsPath: false, + }), + remove: async () => { + environmentRemoves += 1; + return { status: "removed" }; + }, + }); + setPluginEnvironmentProviderBridge({ + listEnvironmentProviders: () => [ + { + pluginId: "test-attached-environment-plugin", + provider: environmentProvider, + }, + ], + getEnvironmentProvider: (id) => + id === environmentProvider.id + ? { + pluginId: "test-attached-environment-plugin", + provider: environmentProvider, + } + : undefined, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + let resumes = 0; + let removes = 0; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 1, + retire: { after: "last-thread", graceMs: 0 }, + removeRetryMs: 10, + }, + suspend: async ({ resource }) => ({ resource }), + resume: async () => { + resumes += 1; + throw new Error("snapshot restoration has no capacity"); + }, + remove: async () => { + removes += 1; + return { status: "removed" }; + }, + }), + ); + adoptMachine(harness, host.id, { snapshot: "snap-remove-directly" }); + updateHost(harness.db, harness.hub, host.id, { + phase: "suspended", + suspendedAt: 19_000, + }); + harness.hub.unregisterDaemon(session.id); + + await expect( + sweepProviderMachine(harness.deps, host.id), + ).resolves.toBeUndefined(); + + expect(resumes).toBe(0); + expect(environmentRemoves).toBe(1); + expect(removes).toBe(1); + expect(getEnvironment(harness.db, environment.id)).toMatchObject({ + status: "destroyed", + }); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "destroyed", + teardownStatus: "removed", + }); + })); + + it("records a machine lifecycle failure and continues sweeping later machines", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(30_000); + const { host: failingHost } = seedHostSession(harness.deps, { + id: "host_a_failing_suspend", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: failingHost.id, + path: "/tmp/failing-suspend", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: failingHost.id, + path: "/tmp/failing-suspend", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ updatedAt: 1_000 }) + .where(eq(threads.id, thread.id)) + .run(); + const { host: removableHost } = seedHostSession(harness.deps, { + id: "host_b_removable", + }); + let removes = 0; + installMachineProvider( + machineDeclaration(failingHost.id, { + policy: { + idleSuspendMs: 5_000, + retire: { after: "never" }, + removeRetryMs: 10, + }, + suspend: async ({ hostId, resource }) => { + if (hostId === failingHost.id) { + throw new Error("snapshot service unavailable"); + } + return { resource }; + }, + resume: async ({ resource }) => ({ resource }), + remove: async () => { + removes += 1; + return { status: "removed" }; + }, + }), + ); + adoptMachine(harness, failingHost.id); + adoptMachine(harness, removableHost.id); + expect(requestMachineRemoval(harness.deps, removableHost.id)).toBe(true); + + await expect( + sweepMachineLifecycles(harness.deps), + ).resolves.toBeUndefined(); + expect(getHost(harness.db, failingHost.id)).toMatchObject({ + phase: "suspending", + teardownStatus: "failed", + teardownMessage: "snapshot service unavailable", + }); + expect(removes).toBe(1); + expect(getHost(harness.db, removableHost.id)).toMatchObject({ + phase: "destroyed", + teardownStatus: "removed", + }); + })); + + it("keeps a never-policy machine until the user removes it", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { id: "host_never" }); + let removes = 0; + installMachineProvider( + machineDeclaration(host.id, { + remove: async () => { + removes += 1; + return { status: "removed" }; + }, + }), + ); + adoptMachine(harness, host.id); + + await sweepProviderMachine(harness.deps, host.id); + expect(removes).toBe(0); + expect(getHost(harness.db, host.id)?.phase).toBe("active"); + expect(requestMachineRemoval(harness.deps, host.id)).toBe(true); + await sweepProviderMachine(harness.deps, host.id); + expect(removes).toBe(1); + expect(getHost(harness.db, host.id)?.phase).toBe("destroyed"); + })); + + it("removes destroyed-machine project sources and selects a surviving default", async () => + withTestHarness(async (harness) => { + const { host: removedHost } = seedHostSession(harness.deps, { + id: "host_removed_source", + }); + const { host: survivingHost } = seedHostSession(harness.deps, { + id: "host_surviving_source", + }); + const { project, source: removedSource } = seedProjectWithSource( + harness.deps, + { + hostId: removedHost.id, + path: "/tmp/removed-source", + }, + ); + const survivingSource = createProjectSource(harness.db, harness.hub, { + projectId: project.id, + hostId: survivingHost.id, + path: "/tmp/surviving-source", + type: "local_path", + }); + installMachineProvider(machineDeclaration(removedHost.id)); + adoptMachine(harness, removedHost.id); + + expect(requestMachineRemoval(harness.deps, removedHost.id)).toBe(true); + await sweepProviderMachine(harness.deps, removedHost.id); + + expect(listProjectSourcesByProjectIds(harness.db, [project.id])).toEqual([ + expect.objectContaining({ + id: survivingSource.id, + hostId: survivingHost.id, + isDefault: true, + }), + ]); + expect(getDefaultProjectSource(harness.db, project.id)?.id).toBe( + survivingSource.id, + ); + expect( + listProjectSourcesByProjectIds(harness.db, [project.id]).some( + (source) => source.id === removedSource.id, + ), + ).toBe(false); + })); +}); + +describe("machine lifecycle safety regressions", () => { + it("archived stopping thread blocks idle suspension", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host } = seedHostSession(harness.deps, { id: "host_suspend" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/suspend", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/suspend", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ updatedAt: 1_000 }) + .where(eq(threads.id, thread.id)) + .run(); + const busy = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "stopping", + }); + harness.db + .update(threads) + .set({ archivedAt: 9_000 }) + .where(eq(threads.id, busy.id)) + .run(); + let suspends = 0; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 5_000, + retire: { after: "never" }, + removeRetryMs: 10, + }, + suspend: async () => { + suspends += 1; + return { resource: { snapshot: "snap-1" } }; + }, + resume: async ({ resource }) => ({ resource }), + }), + ); + adoptMachine(harness, host.id); + + await sweepProviderMachine(harness.deps, host.id); + expect(suspends).toBe(0); + })); + + it("restoring during machine removal preserves the durable claim and requires replacement", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(20_000); + const { host } = seedHostSession(harness.deps, { id: "host_retire" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/retire", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/retire", + providerOwnsPath: true, + status: "ready", + environmentProvider: { + pluginId: "test-environment-plugin", + environmentProviderId: "test-environment", + instanceKey: "retire-environment", + selection: { + machine: { type: "existing", hostId: host.id }, + inputs: null, + }, + }, + }); + const thread = seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + harness.db + .update(threads) + .set({ archivedAt: 20_000 }) + .where(eq(threads.id, thread.id)) + .run(); + const order: string[] = []; + const cleanupStarted = createDeferredPromise(); + const cleanupRelease = createDeferredPromise(); + const environmentProvider = validatePluginEnvironmentProviderDeclaration({ + id: "test-environment", + displayName: "Test environment", + create: async () => ({ + status: "created", + path: "/tmp/retire", + ownsPath: true, + }), + remove: async () => { + order.push("environment"); + return { status: "removed" }; + }, + }); + setPluginEnvironmentProviderBridge({ + listEnvironmentProviders: () => [ + { + pluginId: "test-environment-plugin", + provider: environmentProvider, + }, + ], + getEnvironmentProvider: (id) => + id === environmentProvider.id + ? { + pluginId: "test-environment-plugin", + provider: environmentProvider, + } + : undefined, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: null, + retire: { after: "last-thread", graceMs: 5_000 }, + removeRetryMs: 10, + }, + remove: async () => { + order.push("machine"); + cleanupStarted.resolve(); + await cleanupRelease.promise; + return { status: "removed" }; + }, + }), + ); + adoptMachine(harness, host.id); + + await sweepProviderMachine(harness.deps, host.id); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "retiring", + retireAt: 25_000, + }); + expect(order).toEqual([]); + vi.setSystemTime(25_001); + const removing = sweepProviderMachine(harness.deps, host.id); + await cleanupStarted.promise; + const response = await harness.app.request( + `/api/v1/threads/${thread.id}/unarchive`, + { method: "POST" }, + ); + expect(response.status).toBe(200); + await sweepProviderMachine(harness.deps, host.id); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "retiring", + teardownStatus: "running", + removalStartedAt: 25_001, + }); + await expect( + ensureHostSessionReadyForWork(harness.deps, { hostId: host.id }), + ).rejects.toThrow("Machine removal has begun"); + seedReadyLaunch(harness, { key: thread.id, hostId: host.id }); + expect(resolveThreadMachineLaunchKey(harness.deps, thread.id)).toBe( + thread.id + ":replacement:" + host.id, + ); + cleanupRelease.resolve(); + await removing; + expect(getHost(harness.db, host.id)?.phase).toBe("destroyed"); + })); +}); + +it("cancelled launch cleanup persists and honors removeRetryMs", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(20000); + const { host } = seedHostSession(harness.deps, { + id: "host_cleanup_backoff", + }); + let removes = 0; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 60000, + }, + remove: async () => { + removes++; + return { status: "failed", message: "vendor rate limit" }; + }, + }), + ); + seedReadyLaunch(harness, { key: "failed-cleanup", hostId: host.id }); + const launch = getMachineLaunch(harness.db, "failed-cleanup")!; + updateMachineLaunchAttempt(harness.db, { + ...launch, + phase: "cancelled", + cancelPending: true, + cleanupResourceRemoved: false, + }); + for (let n = 0; n < 3; n++) await sweepMachineLifecycles(harness.deps); + expect(removes).toBe(1); + expect(getMachineLaunch(harness.db, "failed-cleanup")?.cleanupRetryAt).toBe( + 80000, + ); + vi.setSystemTime(79999); + await sweepMachineLifecycles(harness.deps); + expect(removes).toBe(1); + vi.setSystemTime(80000); + await sweepMachineLifecycles(harness.deps); + expect(removes).toBe(2); + })); + +it("cancel before allocation reconciles without starting a fresh allocation", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "host_cancel_before_alloc", + }); + const started = createDeferredPromise(); + let creates = 0; + let allocations = 0; + const record = installMachineProvider( + machineDeclaration(host.id, { + create: async ({ signal }) => { + creates++; + if (creates === 1) { + started.resolve(); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + signal.throwIfAborted(); + } + allocations++; + return { + status: "created", + hostId: host.id, + resource: { allocated: true }, + }; + }, + }), + ); + askMachineLaunch(harness.deps, { + key: "cancel-before-allocation", + record, + projectId: null, + inputs: null, + }); + await started.promise; + await cancelMachineLaunch(harness.deps, "cancel-before-allocation"); + expect(allocations).toBe(0); + expect(creates).toBe(1); + expect( + getMachineLaunch(harness.db, "cancel-before-allocation"), + ).toMatchObject({ cancelPending: false, cleanupResourceRemoved: true }); + })); + +it("retains a persisted removal claim after restart even when live work is restored", async () => + withTestHarness(async (harness) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(20_000); + const { host } = seedHostSession(harness.deps, { + id: "claimed-after-restart", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + path: "/tmp/claimed", + }); + const environment = createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/claimed", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + seedThread(harness.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + const remove = vi.fn(async () => ({ status: "removed" as const })); + installMachineProvider(machineDeclaration(host.id, { remove })); + adoptMachine(harness, host.id); + updateHost(harness.db, harness.hub, host.id, { + phase: "retiring", + removalStartedAt: 10_000, + retireAt: 20_001, + teardownStatus: "failed", + }); + await sweepProviderMachine(harness.deps, host.id); + expect(remove).not.toHaveBeenCalled(); + await expect( + ensureHostSessionReadyForWork(harness.deps, { hostId: host.id }), + ).rejects.toThrow("Machine removal has begun"); + vi.setSystemTime(20_001); + await sweepProviderMachine(harness.deps, host.id); + expect(remove).toHaveBeenCalledOnce(); + expect(getHost(harness.db, host.id)).toMatchObject({ + phase: "destroyed", + removalStartedAt: 10_000, + }); + })); + +it("returns a durable launch before allocation and client disconnect does not cancel", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: "durable-disconnect", + }); + const release = createDeferredPromise(); + let providerSignal: AbortSignal | undefined; + installMachineProvider( + machineDeclaration(host.id, { + create: async ({ signal }) => { + providerSignal = signal; + await release.promise; + return { + status: "created", + hostId: host.id, + resource: { allocated: true }, + }; + }, + }), + ); + const controller = new AbortController(); + const response = await harness.app.request("/api/v1/hosts", { + method: "POST", + signal: controller.signal, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + key: "disconnect", + machineProviderId: "test-machine", + projectId: null, + inputs: null, + }), + }); + expect(response.status).toBe(201); + expect(await response.json()).toMatchObject({ + id: "disconnect", + phase: "creating", + }); + controller.abort(); + expect(providerSignal?.aborted).toBe(false); + release.resolve(); + await expect + .poll(() => getMachineLaunch(harness.db, "disconnect")?.phase) + .toBe("ready"); + const status = await harness.app.request( + "/api/v1/hosts/launches/disconnect", + ); + expect(await status.json()).toMatchObject({ + phase: "ready", + hostId: host.id, + }); + })); + +it("explicit cancel settles enrollment, tombstones pending hosts and aborts creation", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { id: "explicit-cancel" }); + seedPendingEnrollment(harness, host.id, "explicit-cancel"); + const started = createDeferredPromise(); + installMachineProvider( + machineDeclaration(host.id, { + create: async ({ key, signal }) => { + updateMachineLaunchAttempt(harness.db, { + key, + attempt: 1, + hostId: host.id, + }); + started.resolve(); + await new Promise((resolve) => + signal.addEventListener("abort", () => resolve(), { once: true }), + ); + signal.throwIfAborted(); + throw new Error("unreachable"); + }, + }), + ); + await submitMachine(harness.deps, { + key: "explicit-cancel", + machineProviderId: "test-machine", + projectId: null, + inputs: null, + }); + await started.promise; + vi.spyOn(serverAccess, "release").mockImplementation(async () => { + expect( + (await harness.app.request(`/api/v1/hosts/${host.id}`)).status, + ).toBe(200); + }); + const response = await harness.app.request( + "/api/v1/hosts/launches/explicit-cancel/cancel", + { method: "POST" }, + ); + expect(await response.json()).toMatchObject({ + phase: "cancelled", + cancelPending: false, + }); + expectSettledEnrollment(harness, host.id); + expect((await harness.app.request(`/api/v1/hosts/${host.id}`)).status).toBe( + 404, + ); + })); + +function seedPendingEnrollment( + harness: TestAppHarness, + hostId: string, + key: string, +): void { + harness.db + .insert(machineEnrollments) + .values({ + id: `enroll-${key}`, + owner: "test-plugin", + key, + hostId, + state: "pending", + encryptedBootstrap: "encrypted-fixture", + expiresAt: Date.now() + 600000, + createdAt: Date.now(), + updatedAt: Date.now(), + }) + .run(); +} + +function expectSettledEnrollment( + harness: TestAppHarness, + hostId: string, +): void { + expect( + harness.db + .select() + .from(machineEnrollments) + .where(eq(machineEnrollments.hostId, hostId)) + .get(), + ).toMatchObject({ + state: "cancelled", + encryptedBootstrap: null, + expiresAt: null, + }); +} + +it("definitive pre-allocation rejection fails immediately without reconciliation or a ghost host", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { id: "rejected-create" }); + seedPendingEnrollment(harness, host.id, "rejected-create"); + const reconcile = vi.fn(async () => ({ status: "removed" as const })); + const create = vi.fn(async ({ key }: { key: string }) => { + updateMachineLaunchAttempt(harness.db, { + key, + attempt: 1, + hostId: host.id, + }); + return { + status: "failed" as const, + failure: "terminal" as const, + allocation: "none" as const, + message: "Vendor rejected allocation", + }; + }); + installMachineProvider( + machineDeclaration(host.id, { + create, + experimental_reconcileCleanup: reconcile, + }), + ); + await submitMachine(harness.deps, { + key: "rejected-create", + machineProviderId: "test-machine", + projectId: null, + inputs: null, + }); + await expect + .poll( + () => + getMachineLaunch(harness.db, "rejected-create") + ?.cleanupResourceRemoved, + ) + .toBe(true); + await sweepMachineLifecycles(harness.deps); + expect(create).toHaveBeenCalledOnce(); + expect(reconcile).not.toHaveBeenCalled(); + expect(getMachineLaunch(harness.db, "rejected-create")).toMatchObject({ + phase: "failed", + cancelPending: false, + }); + expectSettledEnrollment(harness, host.id); + expect((await harness.app.request(`/api/v1/hosts/${host.id}`)).status).toBe( + 404, + ); + })); + +it("removal settles enrollment and repeating settlement is idempotent", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { id: "remove-enrollment" }); + seedPendingEnrollment(harness, host.id, "remove-enrollment"); + installMachineProvider(machineDeclaration(host.id)); + adoptMachine(harness, host.id); + expect(requestMachineRemoval(harness.deps, host.id)).toBe(true); + await sweepProviderMachine(harness.deps, host.id); + expectSettledEnrollment(harness, host.id); + const settled = harness.db.select().from(machineEnrollments).all(); + await sweepProviderMachine(harness.deps, host.id); + expect(harness.db.select().from(machineEnrollments).all()).toEqual(settled); + })); + +it("resume crash after checkpoint recovers the allocation and preserves one enrollment", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { id: "resume-checkpoint" }); + seedPendingEnrollment(harness, host.id, "resume-checkpoint"); + adoptMachine(harness, host.id, { snapshot: "saved" }); + updateHost(harness.db, harness.hub, host.id, { + phase: "suspended", + suspendedAt: Date.now(), + }); + let allocations = 0; + installMachineProvider( + machineDeclaration(host.id, { + suspend: async ({ resource }) => ({ resource }), + resume: async ({ resource, checkpoint }) => { + if (allocations === 0) { + allocations++; + await checkpoint({ snapshot: "saved", sandbox: "restored" }); + throw new Error("crash before bootstrap"); + } + expect(resource).toEqual({ snapshot: "saved", sandbox: "restored" }); + return { resource }; + }, + }), + ); + await expect(resumeMachine(harness.deps, host.id)).rejects.toThrow( + "crash before bootstrap", + ); + const token = getHost(harness.db, host.id)?.machineOperationId; + await resumeMachine(harness.deps, host.id); + expect(allocations).toBe(1); + expect(getHost(harness.db, host.id)?.machineOperationId).not.toBe(token); + expect(getHost(harness.db, host.id)?.resource).toEqual({ + snapshot: "saved", + sandbox: "restored", + }); + expect(harness.db.select().from(machineEnrollments).all()).toHaveLength(1); + })); + +it.each(["owner", "operation", "removal", "phase"])( + "rejects stale resume checkpoints and completion after competing %s", + async (change) => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: `resume-fence-${change}`, + }); + adoptMachine(harness, host.id, { snapshot: "saved" }); + updateHost(harness.db, harness.hub, host.id, { + phase: "suspended", + suspendedAt: Date.now(), + }); + installMachineProvider( + machineDeclaration(host.id, { + suspend: async ({ resource }) => ({ resource }), + resume: async ({ checkpoint }) => { + if (change === "owner") + updateHost(harness.db, harness.hub, host.id, { + machineProviderId: "new-owner", + }); + if (change === "operation") + updateHost(harness.db, harness.hub, host.id, { + machineOperationId: "new-operation", + }); + if (change === "phase") + updateHost(harness.db, harness.hub, host.id, { phase: "active" }); + if (change === "removal") + requestMachineRemoval(harness.deps, host.id); + updateHost(harness.db, harness.hub, host.id, { + resource: { newer: true }, + }); + await expect(checkpoint({ stale: true })).rejects.toThrow( + "no longer owns", + ); + return { resource: { staleCompletion: true } }; + }, + }), + ); + await resumeMachine(harness.deps, host.id); + expect(getHost(harness.db, host.id)?.resource).toEqual({ newer: true }); + }), +); + +it("bounds unresolved allocation cleanup retries and keeps the failed host tombstoned", async () => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { id: "bounded-reconcile" }); + seedPendingEnrollment(harness, host.id, "bounded-reconcile"); + const reconcile = vi.fn(async () => ({ + status: "failed" as const, + message: "Allocation outcome unknown", + })); + installMachineProvider( + machineDeclaration(host.id, { experimental_reconcileCleanup: reconcile }), + ); + seedReadyLaunch(harness, { key: "bounded-reconcile", hostId: host.id }); + updateHost(harness.db, harness.hub, host.id, { machineProviderId: null }); + updateMachineLaunchAttempt(harness.db, { + key: "bounded-reconcile", + attempt: 1, + phase: "failed", + failure: "terminal", + resource: null, + startedAt: Date.now() - 31 * 60_000, + cleanupResourceRemoved: false, + }); + await expect( + cancelMachineLaunch(harness.deps, "bounded-reconcile", true), + ).rejects.toThrow("Allocation outcome unknown"); + for (let n = 0; n < 3; n++) await sweepMachineLifecycles(harness.deps); + expect(reconcile).toHaveBeenCalledOnce(); + expectSettledEnrollment(harness, host.id); + expect((await harness.app.request(`/api/v1/hosts/${host.id}`)).status).toBe( + 404, + ); + await expect( + cancelMachineLaunch(harness.deps, "bounded-reconcile", true, true), + ).rejects.toThrow("Allocation outcome unknown"); + expect(reconcile).toHaveBeenCalledTimes(2); + })); + +it.each(["owner", "operation", "phase"])( + "fences removal completion after competing %s", + async (change) => + withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { + id: `remove-fence-${change}`, + }); + adoptMachine(harness, host.id, { allocated: true }); + installMachineProvider( + machineDeclaration(host.id, { + remove: async () => { + updateHost(harness.db, harness.hub, host.id, { + resource: { newer: true }, + ...(change === "owner" ? { machineProviderId: "new-owner" } : {}), + ...(change === "operation" + ? { machineOperationId: "new-operation" } + : {}), + ...(change === "phase" ? { phase: "active" as const } : {}), + }); + return { status: "removed" }; + }, + }), + ); + requestMachineRemoval(harness.deps, host.id); + await sweepProviderMachine(harness.deps, host.id); + expect(getHost(harness.db, host.id)).toMatchObject({ + resource: { newer: true }, + destroyedAt: null, + }); + }), +); + +it.each(["SDK follow", "server create"])( + "%s survives a server-owned transient retry", + async (client) => + withTestHarness(async (h) => { + const { host } = seedHostSession(h.deps, { id: "review-follow" }); + let creates = 0; + installMachineProvider( + machineDeclaration(host.id, { + create: async () => { + if (++creates === 1) + return { + status: "failed", + failure: "transient", + message: "temporary vendor failure", + }; + return { + status: "created", + hostId: host.id, + resource: { id: "allocated" }, + }; + }, + }), + ); + const sdk = createBbSdk({ + transport: createHttpTransport({ + runtime: "node", + baseUrl: "http://bb.test", + fetch: async (input, init) => h.app.request(input, init), + }), + }); + const launch = await sdk.hosts.submit({ + key: "review-follow", + machineProviderId: "test-machine", + projectId: null, + inputs: null, + }); + await expect + .poll(() => getMachineLaunch(h.db, launch.id)?.phase) + .toBe("failed"); + expect(await sdk.hosts.launch({ id: launch.id })).toMatchObject({ + phase: "failed", + terminal: false, + }); + const following = ( + client === "SDK follow" + ? sdk.hosts.follow({ id: launch.id }) + : createMachine(h.deps, { + key: launch.id, + machineProviderId: "test-machine", + projectId: null, + inputs: null, + }) + ).then( + (value) => ({ ok: true, value }), + (error) => ({ ok: false, error: String(error) }), + ); + await new Promise((resolve) => setTimeout(resolve, 25)); + updateMachineLaunchAttempt(h.db, { + key: launch.id, + attempt: 1, + failedAt: Date.now() - 31000, + }); + await sweepMachineLifecycles(h.deps); + await expect + .poll(() => getMachineLaunch(h.db, launch.id)?.phase) + .toBe("ready"); + const result = await following; + expect(result.ok).toBe(true); + }), +); + +it("known allocated resource keeps retrying removal after the launch window", async () => + withTestHarness(async (h) => { + const { host } = seedHostSession(h.deps, { id: "review-removal-window" }); + let removes = 0; + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 10, + }, + remove: async () => + ++removes === 1 + ? { status: "failed", message: "vendor unavailable" } + : { status: "removed" }, + }), + ); + seedReadyLaunch(h, { key: "review-removal-window", hostId: host.id }); + updateMachineLaunchAttempt(h.db, { + key: "review-removal-window", + attempt: 1, + phase: "cancelled", + cancelPending: true, + cleanupResourceRemoved: false, + startedAt: Date.now() - 31 * 60000, + }); + await expect( + cancelMachineLaunch(h.deps, "review-removal-window"), + ).rejects.toThrow("vendor unavailable"); + await new Promise((resolve) => setTimeout(resolve, 25)); + await sweepMachineLifecycles(h.deps); + expect(removes).toBe(2); + expect(getMachineLaunch(h.db, "review-removal-window")).toMatchObject({ + cancelPending: false, + resource: null, + cleanupRetryAt: null, + }); + })); + +it("periodic retirement does not invalidate an in-flight resume allocation", async () => + withTestHarness(async (h) => { + const { host } = seedHostSession(h.deps, { id: "review-resume-retire" }); + adoptMachine(h, host.id, { snapshot: "image" }); + updateHost(h.db, h.hub, host.id, { + phase: "suspended", + suspendedAt: Date.now(), + }); + const allocated = createDeferredPromise(); + const proceed = createDeferredPromise(); + installMachineProvider( + machineDeclaration(host.id, { + policy: { + idleSuspendMs: 60000, + retire: { after: "last-thread", graceMs: 30 * 24 * 60 * 60000 }, + removeRetryMs: 10, + }, + suspend: async ({ resource }) => ({ resource }), + resume: async ({ checkpoint }) => { + allocated.resolve(); + await proceed.promise; + await checkpoint({ snapshot: "image", sandbox: "new-sandbox" }); + return { resource: { snapshot: "image", sandbox: "new-sandbox" } }; + }, + }), + ); + const resuming = resumeMachine(h.deps, host.id).then( + () => ({ ok: true }), + (error) => ({ ok: false, error: String(error) }), + ); + await allocated.promise; + await sweepProviderMachine(h.deps, host.id); + proceed.resolve(); + expect(await resuming).toEqual({ ok: true }); + expect(getHost(h.db, host.id)?.resource).toMatchObject({ + sandbox: "new-sandbox", + }); + await sweepProviderMachine(h.deps, host.id); + expect(getHost(h.db, host.id)?.phase).toBe("retiring"); + })); + +describe("finite machine lifecycle", () => { + it("excludes dispatch and durably saves before terminating a machine with no live threads", async () => + withTestHarness(async (h) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host } = seedHostSession(h.deps, { id: "host_deadline" }); + adoptMachine(h, host.id); + const saving = createDeferredPromise(); + const proceed = createDeferredPromise(); + let terminated = false; + installMachineProvider( + machineDeclaration(host.id, { + experimental_observe: async ({ resource }) => ({ + state: "running", + expiresAt: 20_000, + resource, + }), + experimental_policy: async () => ({ + idleSuspendMs: null, + retireAfterMs: 30 * 86400_000, + deadlineLeadMs: 15_000, + }), + suspend: async ({ checkpoint }) => { + saving.resolve(); + await proceed.promise; + checkpoint({ snapshot: "durable" }, Date.now()); + expect(getHost(h.db, host.id)?.resource).toEqual({ + snapshot: "durable", + }); + expect(getMachineLifecycle(h.deps, host.id)?.lastSnapshotAt).toBe( + 10_000, + ); + terminated = true; + return { resource: { snapshot: "durable" } }; + }, + resume: async ({ resource }) => ({ resource }), + }), + ); + const sweep = sweepProviderMachine(h.deps, host.id); + await saving.promise; + expect(() => assertMachineLifecycleAdmission(h.deps, host.id)).toThrow( + "Saving the filesystem", + ); + expect(terminated).toBe(false); + proceed.resolve(); + await sweep; + expect(terminated).toBe(true); + expect(getHost(h.db, host.id)?.phase).toBe("suspended"); + expect(machineLifecycleStatus(h.deps, host.id, {})).toMatchObject({ + recoveryState: "saved", + lastSnapshotAt: 10_000, + expiresAt: null, + }); + })); + + it("retains compute after failed save and retries after a durable lease expires", async () => + withTestHarness(async (h) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host } = seedHostSession(h.deps, { id: "host_failed_save" }); + adoptMachine(h, host.id); + let fails = true; + let saves = 0; + installMachineProvider( + machineDeclaration(host.id, { + experimental_observe: async ({ resource }) => ({ + state: "running", + expiresAt: 100_000, + resource, + }), + experimental_policy: async () => ({ + idleSuspendMs: null, + retireAfterMs: null, + deadlineLeadMs: 95_000, + }), + suspend: async ({ checkpoint }) => { + saves += 1; + if (fails) throw new Error("snapshot unavailable"); + checkpoint({ snapshot: "saved" }, Date.now()); + return { resource: { snapshot: "saved" } }; + }, + resume: async ({ resource }) => ({ resource }), + }), + ); + await expect(sweepProviderMachine(h.deps, host.id)).rejects.toThrow( + "snapshot unavailable", + ); + expect(getHost(h.db, host.id)?.phase).toBe("active"); + expect(getMachineLifecycle(h.deps, host.id)).toMatchObject({ + recoveryState: "recoverable", + lastSnapshotAt: null, + leaseId: null, + }); + await sweepProviderMachine(h.deps, host.id); + expect(saves).toBe(1); + h.db + .update(machineLifecycles) + .set({ + leaseId: "previous-server", + leaseUntil: 40_000, + recoveryState: "saving", + }) + .where(eq(machineLifecycles.hostId, host.id)) + .run(); + vi.setSystemTime(30_000); + await sweepProviderMachine(h.deps, host.id); + expect(saves).toBe(1); + fails = false; + vi.setSystemTime(40_001); + await sweepProviderMachine(h.deps, host.id); + expect(saves).toBe(2); + expect(getMachineLifecycle(h.deps, host.id)?.recoveryState).toBe("saved"); + })); + + it("discloses disappearance without silently restoring an older snapshot", async () => + withTestHarness(async (h) => { + const { host } = seedHostSession(h.deps, { id: "host_lost_save" }); + adoptMachine(h, host.id, { snapshot: "old" }); + const resume = vi.fn(async ({ resource }: { resource: JsonValue }) => ({ + resource, + })); + installMachineProvider( + machineDeclaration(host.id, { + experimental_observe: async ({ resource }) => ({ + state: "missing", + expiresAt: null, + resource, + }), + experimental_policy: async () => ({ + idleSuspendMs: null, + retireAfterMs: null, + deadlineLeadMs: 900_000, + }), + suspend: async ({ resource }) => ({ resource }), + resume, + }), + ); + await expect( + ensureHostSessionReadyForWork(h.deps, { hostId: host.id }), + ).rejects.toThrow( + "Changes since the last successful snapshot may be lost", + ); + expect(resume).not.toHaveBeenCalled(); + expect(machineLifecycleStatus(h.deps, host.id, {}).recoveryState).toBe( + "lost-since-last-snapshot", + ); + })); + + it("updates retention and idle policy without reloading and honors keep", async () => + withTestHarness(async (h) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host } = seedHostSession(h.deps, { id: "host_live_policy" }); + adoptMachine(h, host.id); + let idleSuspendMs = 100_000; + let retireAfterMs = 200_000; + let removed = false; + installMachineProvider( + machineDeclaration(host.id, { + experimental_observe: async ({ resource }) => ({ + state: "running", + expiresAt: 1_000_000, + resource, + }), + experimental_policy: async () => ({ + idleSuspendMs, + retireAfterMs, + deadlineLeadMs: 10_000, + }), + remove: async () => { + removed = true; + return { status: "removed" }; + }, + suspend: async ({ resource, checkpoint }) => { + checkpoint(resource, Date.now()); + return { resource }; + }, + resume: async ({ resource }) => ({ resource }), + }), + ); + await sweepProviderMachine(h.deps, host.id); + expect(machineLifecycleStatus(h.deps, host.id, {}).retentionAt).toBe( + 210_000, + ); + const sdk = createBbSdk({ + transport: createHttpTransport({ + runtime: "node", + baseUrl: "http://bb.test", + fetch: async (input, init) => h.app.request(input, init), + }), + }); + expect( + await sdk.hosts.experimental_lifecycle({ hostId: host.id, keep: true }), + ).toMatchObject({ keep: true, retentionAt: null }); + vi.setSystemTime(15_000); + idleSuspendMs = 1_000; + retireAfterMs = 1_000; + await sweepProviderMachine(h.deps, host.id); + expect(getHost(h.db, host.id)?.phase).toBe("suspended"); + expect(removed).toBe(false); + expect( + machineLifecycleStatus(h.deps, host.id, {}).retentionAt, + ).toBeNull(); + machineLifecycleStatus(h.deps, host.id, { keep: false }); + await sweepProviderMachine(h.deps, host.id); + expect(removed).toBe(true); + })); + + it("fails observation closed on an account identity mismatch", async () => + withTestHarness(async (h) => { + const { host } = seedHostSession(h.deps, { id: "host_account_changed" }); + adoptMachine(h, host.id); + installMachineProvider( + machineDeclaration(host.id, { + experimental_observe: async () => { + throw new Error("Restore the pinned account"); + }, + experimental_policy: async () => ({ + idleSuspendMs: null, + retireAfterMs: null, + deadlineLeadMs: null, + }), + }), + ); + await expect(observeMachineLifecycle(h.deps, host.id)).rejects.toThrow( + "Restore the pinned account", + ); + })); +}); + +it.each([false, true])( + "deadline drain preserves an interrupted active turn and refuses failed stop (%s)", + async (stopFails) => + withTestHarness(async (h) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host, session } = seedHostSession(h.deps, { + id: "host_drain_active", + }); + const { project } = seedProjectWithSource(h.deps, { + hostId: host.id, + path: "/tmp/drain-active", + }); + const environment = createEnvironment(h.db, h.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/drain-active", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(h.deps, { + projectId: project.id, + environmentId: environment.id, + status: "active", + }); + seedThreadRuntimeState(h.deps, { + threadId: thread.id, + environmentId: environment.id, + providerThreadId: "provider-drain", + }); + seedTurnStarted(h.deps, { + threadId: thread.id, + environmentId: environment.id, + turnId: "turn-drain", + }); + const terminal = createTerminalSession(h.db, { + threadId: thread.id, + environmentId: environment.id, + hostId: host.id, + daemonSessionId: null, + title: "open terminal", + initialCwd: "/tmp/drain-active", + cols: 80, + rows: 24, + status: "disconnected", + }); + adoptMachine(h, host.id); + const responder = registerHostRpcResponder(h, { + hostId: host.id, + sessionId: session.id, + handle: (request) => { + expect(request.command.type).toBe("thread.stop"); + return stopFails + ? { + ok: false, + errorCode: "stop_failed", + errorMessage: "Provider refused stop", + } + : { ok: true, result: { providerCheckpointId: null } }; + }, + }); + let saves = 0; + installMachineProvider( + machineDeclaration(host.id, { + experimental_observe: async ({ resource }) => ({ + state: "running", + expiresAt: 20_000, + resource, + }), + experimental_policy: async () => ({ + idleSuspendMs: 1, + retireAfterMs: null, + deadlineLeadMs: 15_000, + }), + suspend: async ({ resource, checkpoint }) => { + saves += 1; + checkpoint(resource, Date.now()); + return { resource }; + }, + resume: async ({ resource }) => ({ resource }), + }), + ); + if (stopFails) { + await expect(sweepProviderMachine(h.deps, host.id)).rejects.toThrow( + "Provider refused stop", + ); + expect(saves).toBe(0); + expect(getHost(h.db, host.id)?.phase).toBe("active"); + } else { + await sweepProviderMachine(h.deps, host.id); + expect(saves).toBe(1); + expect(getThread(h.db, thread.id)?.status).not.toBe("active"); + expect( + h.db + .select() + .from(terminalSessions) + .where(eq(terminalSessions.id, terminal.id)) + .get()?.status, + ).toBe("exited"); + const recorded = h.db + .select() + .from(events) + .where(eq(events.threadId, thread.id)) + .all(); + expect( + recorded.some((event) => event.type === "system/thread/interrupted"), + ).toBe(true); + expect( + recorded + .filter((event) => event.type === "turn/completed") + .map((event) => event.data), + ).not.toContainEqual(expect.objectContaining({ status: "completed" })); + } + expect( + responder.requests.some( + (request) => request.command.type === "thread.stop", + ), + ).toBe(true); + }), +); + +it("concurrent dispatch shares one observed restore and records an expired image failure", async () => + withTestHarness(async (h) => { + const { host } = seedHostSession(h.deps, { id: "host_observed_restore" }); + adoptMachine(h, host.id, { snapshot: "saved-image" }); + updateHost(h.db, h.hub, host.id, { + phase: "suspended", + suspendedAt: Date.now(), + }); + const restoring = createDeferredPromise(); + const proceed = createDeferredPromise(); + let running = false; + let expired = false; + let resumes = 0; + installMachineProvider( + machineDeclaration(host.id, { + experimental_observe: async ({ resource }) => ({ + state: running ? "running" : "suspended", + expiresAt: running ? Date.now() + 86_400_000 : null, + resource, + }), + experimental_policy: async () => ({ + idleSuspendMs: 900_000, + retireAfterMs: 30 * 86_400_000, + deadlineLeadMs: 900_000, + }), + suspend: async ({ resource }) => ({ resource }), + resume: async ({ resource, checkpoint }) => { + resumes += 1; + restoring.resolve(); + await proceed.promise; + if (expired) throw new Error("Snapshot image no longer exists"); + await checkpoint({ + snapshot: "saved-image", + sandbox: "restored-once", + }); + running = true; + return { resource }; + }, + }), + ); + const first = ensureHostSessionReadyForWork(h.deps, { hostId: host.id }); + await restoring.promise; + const second = ensureHostSessionReadyForWork(h.deps, { hostId: host.id }); + proceed.resolve(); + await Promise.all([first, second]); + expect(resumes).toBe(1); + expect(getMachineLifecycle(h.deps, host.id)).toMatchObject({ + recoveryState: "healthy", + observedState: "running", + }); + running = false; + expired = true; + updateHost(h.db, h.hub, host.id, { + phase: "suspended", + suspendedAt: Date.now(), + }); + await expect( + ensureHostSessionReadyForWork(h.deps, { hostId: host.id }), + ).rejects.toThrow("Snapshot image no longer exists"); + expect(machineLifecycleStatus(h.deps, host.id, {})).toMatchObject({ + recoveryState: "recoverable", + message: expect.stringContaining("Snapshot image no longer exists"), + }); + expect(getHost(h.db, host.id)?.phase).toBe("suspended"); + })); + +it("wakes persisted offline queue intent after a suspended machine is reconciled", async () => + withTestHarness(async (h) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(10_000); + const { host } = seedHostSession(h.deps, { id: "host_queued_wake" }); + adoptMachine(h, host.id, { snapshot: "saved-image" }); + updateHost(h.db, h.hub, host.id, { + phase: "suspended", + suspendedAt: Date.now(), + }); + const { project } = seedProjectWithSource(h.deps, { + hostId: host.id, + path: "/tmp/queued-wake", + }); + const environment = createEnvironment(h.db, h.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/queued-wake", + providerOwnsPath: false, + status: "ready", + environmentProvider: null, + }); + const thread = seedThread(h.deps, { + projectId: project.id, + environmentId: environment.id, + status: "idle", + }); + let running = false; + let resumes = 0; + let suspends = 0; + let observations = 0; + installMachineProvider( + machineDeclaration(host.id, { + experimental_observe: async ({ resource }) => { + observations += 1; + return { + state: running ? "running" : "suspended", + expiresAt: null, + resource, + }; + }, + experimental_policy: async () => ({ + idleSuspendMs: 1_000, + retireAfterMs: 30 * 86_400_000, + deadlineLeadMs: 900_000, + }), + suspend: async ({ resource }) => { + suspends += 1; + return { resource }; + }, + resume: async ({ resource }) => { + expect(observations).toBeGreaterThan(0); + resumes += 1; + running = true; + return { resource }; + }, + }), + ); + await sweepProviderMachine(h.deps, host.id); + expect(resumes).toBe(0); + vi.setSystemTime(20_000); + createQueuedThreadMessage(h.db, h.hub, { + threadId: thread.id, + content: [{ type: "text", text: "continue after restart", mentions: [] }], + model: "gpt-5", + reasoningLevel: "medium", + permissionMode: "auto", + serviceTier: "default", + waitingOn: { kind: "host-offline", hostName: "previous host name" }, + sendAt: null, + payload: { kind: "inline" }, + systemNotice: null, + }); + await sweepProviderMachine(h.deps, host.id); + expect(resumes).toBe(1); + await sweepProviderMachine(h.deps, host.id); + expect(suspends).toBe(0); + expect(getHost(h.db, host.id)?.phase).toBe("active"); + expect(getMachineLifecycle(h.deps, host.id)?.observedState).toBe("running"); + })); + +it("settles an abandoned maintenance lease after persisted suspension and admits the saved machine", async () => + withTestHarness(async (h) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(100_000); + const { host } = seedHostSession(h.deps, { id: "abandoned-maintenance" }); + adoptMachine(h, host.id, { snapshot: "durable" }); + updateHost(h.db, h.hub, host.id, { + phase: "suspended", + suspendedAt: 40_000, + }); + const observe = vi.fn< + NonNullable + >(async ({ resource }) => ({ + state: "suspended" as const, + expiresAt: null, + resource, + })); + installMachineProvider( + machineDeclaration(host.id, { + experimental_observe: observe, + experimental_policy: async () => ({ + idleSuspendMs: 900_000, + retireAfterMs: 2592000000, + deadlineLeadMs: 900_000, + }), + suspend: async ({ resource }) => ({ resource }), + resume: async ({ resource }) => ({ resource }), + }), + ); + h.db + .insert(machineLifecycles) + .values({ + hostId: host.id, + observedState: "suspended", + observedAt: 40_000, + recoveryState: "saving", + lastSnapshotAt: 40_000, + leaseId: "previous-process", + leaseUntil: 70_000, + }) + .run(); + expect(() => assertMachineLifecycleAdmission(h.deps, host.id)).toThrow( + "preserving", + ); + await sweepProviderMachine(h.deps, host.id); + expect(observe).toHaveBeenCalledTimes(1); + expect(getMachineLifecycle(h.deps, host.id)).toMatchObject({ + leaseId: null, + leaseUntil: null, + recoveryState: "saved", + observedState: "suspended", + lastSnapshotAt: 40_000, + }); + expect(getHost(h.db, host.id)?.resource).toEqual({ snapshot: "durable" }); + expect(() => + assertMachineLifecycleAdmission(h.deps, host.id), + ).not.toThrow(); + })); + +it("discards an older observation failure after a newer observation confirms preservation", async () => + withTestHarness(async (h) => { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(100_000); + const { host } = seedHostSession(h.deps, { id: "observation-race" }); + adoptMachine(h, host.id, { snapshot: "durable" }); + updateHost(h.db, h.hub, host.id, { + phase: "suspended", + suspendedAt: 40_000, + }); + const entered = createDeferredPromise(); + const older = createDeferredPromise(); + let calls = 0; + installMachineProvider( + machineDeclaration(host.id, { + experimental_observe: async ({ resource }) => { + if (++calls === 1) { + entered.resolve(); + await older.promise; + } + return { state: "suspended", expiresAt: null, resource }; + }, + experimental_policy: async () => ({ + idleSuspendMs: 900_000, + retireAfterMs: 2592000000, + deadlineLeadMs: 900_000, + }), + }), + ); + h.db + .insert(machineLifecycles) + .values({ + hostId: host.id, + observedState: "running", + observedAt: 40_000, + expiresAt: 99_000, + recoveryState: "saving", + lastSnapshotAt: 40_000, + leaseId: "previous-process", + leaseUntil: 70_000, + }) + .run(); + const pending = observeMachineLifecycle(h.deps, host.id); + await entered.promise; + await observeMachineLifecycle(h.deps, host.id); + const saved = getMachineLifecycle(h.deps, host.id); + expect(saved).toMatchObject({ + recoveryState: "saved", + leaseId: null, + expiresAt: null, + observedState: "suspended", + }); + expect(() => assertMachineLifecycleAdmission(h.deps, host.id)).not.toThrow(); + older.reject(new Error("observation transport timeout")); + await expect(pending).resolves.toBeUndefined(); + expect(getMachineLifecycle(h.deps, host.id)).toEqual(saved); + expect(() => assertMachineLifecycleAdmission(h.deps, host.id)).not.toThrow(); + })); 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..2114205ba2 --- /dev/null +++ b/apps/server/test/services/machines/runtime-enrollments.test.ts @@ -0,0 +1,231 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { eq } from "drizzle-orm"; +import { + getMachineLaunch, + listPublicHosts, + hosts, + machineEnrollments, + machineLaunches, + setAppSettings, +} from "@bb/db"; +import { defaultAppSettings } from "@bb/domain"; +import { describe, expect, it, vi } from "vitest"; +import { getMachineEnrollmentService } from "../../../src/services/machines/machine-services.js"; +import { serverAccess } from "../../../src/services/machines/server-access.js"; +import { + withTestHarness, + type TestAppHarness, +} from "../../helpers/test-app.js"; + +async function installPlugin(harness: TestAppHarness, id: string) { + const root = join(harness.config.dataDir, `bb-plugin-${id}`); + await mkdir(root, { recursive: true }); + await writeFile( + join(root, "package.json"), + JSON.stringify({ + name: `bb-plugin-${id}`, + version: "0.1.0", + type: "module", + bb: { + name: id, + description: "Machine enrollment regression fixture", + branding: { icon: "Zap" }, + server: "./server.js", + }, + }), + ); + await writeFile( + join(root, "server.js"), + `export default function(bb) { + bb.experimental_machines.register({ + id: "${id}-machine", displayName: "Runtime machine", + policy: { retire: { after: "never" }, idleSuspendMs: null, removeRetryMs: 10 }, + experimental_reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ status: "failed", failure: "terminal", 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(machineLaunches) + .values({ + key, + providerId, + attempt: 1, + phase: "creating", + startedAt: Date.now(), + transientFailures: 0, + stepText: "checkpoint step", + pendingLog: "checkpoint log", + cancelPending: false, + resource: { checkpoint: "preserve" }, + }) + .run(); +} + +describe("production machine enrollment wiring", () => { + 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 enrollment = await api.experimental_machines.prepareEnrollment({ + key: "runtime-launch", + }); + expect(getMachineLaunch(h.db, "runtime-launch")).toMatchObject({ + hostId: enrollment.hostId, + resource: { checkpoint: "preserve" }, + stepText: "checkpoint step", + pendingLog: "checkpoint log", + }); + expect(getMachineEnrollmentService(h.deps)).toBe( + getMachineEnrollmentService(h.deps), + ); + expect( + await api.experimental_machines.enrollments.prepare({ + key: "runtime-launch", + }), + ).toEqual(enrollment); + if (enrollment.state !== "pending") + throw new Error("Expected pending enrollment"); + const exec = vi.fn(async () => { + expect( + await h.deps.machineAuth.enrollHost({ + hostId: enrollment.hostId, + token: enrollment.bootstrap.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 }, + daemon: { kind: "preinstalled" }, + report: { step() {}, log() {} }, + signal: new AbortController().signal, + }), + ).resolves.toEqual({ hostId: enrollment.hostId }); + expect(exec).toHaveBeenCalledOnce(); + expect( + await api.experimental_machines.enrollments.prepare({ + key: "runtime-launch", + }), + ).toEqual({ + id: enrollment.id, + hostId: enrollment.hostId, + state: "enrolled", + }); + await expect( + api.experimental_machines.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.prepareEnrollment({ key: "after-disable" }), + ).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"); + const other = await installPlugin(h, "enrollment-other"); + const release = vi.fn(async () => {}); + const acquire = vi.fn(async ({ hostId }: { hostId: string }) => ({ + id: "runtime-grant", + serverUrl: "https://machine.example.test", + })); + api.experimental_serverAccess.register({ + id: "runtime-access", + displayName: "Runtime access", + availability: () => ({ status: "available" }), + acquire, + release, + }); + launch(h, "failure-launch", "enrollment-runtime-machine"); + await expect( + other.experimental_machines.prepareEnrollment({ + key: "failure-launch", + access: { providerId: "runtime-access" }, + }), + ).rejects.toThrow("different plugin"); + expect(h.db.select().from(machineEnrollments).all()).toEqual([]); + acquire.mockRejectedValueOnce( + Object.assign(new Error("Cloud device may need dashboard revocation"), { + name: "experimental_ServerAccessRecoveryError", + }), + ); + await expect( + api.experimental_machines.prepareEnrollment({ + key: "failure-launch", + access: { providerId: "runtime-access" }, + }), + ).rejects.toThrow(); + const reserved = getMachineLaunch(h.db, "failure-launch"); + expect(reserved?.hostId).toBeTruthy(); + expect(reserved?.resource).toEqual({ checkpoint: "preserve" }); + expect( + listPublicHosts(h.db).find((host) => host.id === reserved?.hostId) + ?.teardownMessage, + ).toBe("Cloud device may need dashboard revocation"); + const enrollment = await api.experimental_machines.prepareEnrollment({ + key: "failure-launch", + access: { providerId: "runtime-access" }, + }); + expect(enrollment.hostId).toBe(reserved?.hostId); + expect( + listPublicHosts(h.db).some((host) => host.id === reserved?.hostId), + ).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(); + expect( + await getMachineEnrollmentService(h.deps).cancelByKey( + "enrollment-runtime", + "failure-launch", + ), + ).toEqual({ hostId: enrollment.hostId }); + const standalone = await api.experimental_machines.prepareEnrollment({ + key: "standalone", + access: { providerId: "runtime-access" }, + }); + expect(standalone.hostId).not.toBe(enrollment.hostId); + expect(getMachineLaunch(h.db, "standalone")).toBeNull(); + }); + }); +}); 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..c5641a2ae1 --- /dev/null +++ b/apps/server/test/services/machines/server-access.test.ts @@ -0,0 +1,250 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + machineEnrollments, + getHost, + setAppSettings, + 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: "connect", + displayName: "bb Cloud", + 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("surfaces access attention in General settings without changing availability", async () => { + await withTestHarness(async ({ deps }) => { + installProvider({ + ...provider(), + experimental_attention: () => "2 legacy access records need attention", + }); + const status = await serverAccessStatus(deps); + expect(status.defaultProviderId).toBe("connect"); + expect( + status.providers.find((entry) => entry.id === "connect"), + ).toMatchObject({ + attention: "2 legacy access records need attention", + availability: { status: "available" }, + }); + expect( + status.providers.find((entry) => entry.id === "direct")?.attention, + ).toBeNull(); + }); + }); + it("prefers paired Connect and respects an explicit direct default", async () => { + await withTestHarness(async ({ deps }) => { + vi.stubEnv("BB_EXTERNAL_URL", "https://direct.example.com"); + installProvider(provider()); + expect((await serverAccessStatus(deps)).defaultProviderId).toBe( + "connect", + ); + 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("Configure"); + }); + }); + + it("keeps access available if its attention hook fails", async () => { + await withTestHarness(async ({ deps }) => { + installProvider({ + ...provider(), + experimental_attention: () => { + throw new Error("private-provider-error"); + }, + }); + const status = await serverAccessStatus(deps); + expect(status.defaultProviderId).toBe("connect"); + expect( + status.providers.find((entry) => entry.id === "connect"), + ).toMatchObject({ + attention: "Access diagnostics are unavailable", + availability: { status: "available" }, + }); + expect(JSON.stringify(status)).not.toContain("private-provider-error"); + }); + }); + + it("returns direct access without headers", async () => { + await withTestHarness(async ({ deps }) => { + vi.stubEnv("BB_EXTERNAL_URL", "https://direct.example.com"); + const host = upsertHost(deps.db, deps.hub, { name: "direct" })!; + const grant = await serverAccess.resolve(deps, { + key: "direct", + hostId: host.id, + access: { providerId: "direct" }, + signal, + }); + expect(grant).toEqual({ + id: host.id, + serverUrl: "https://direct.example.com", + }); + }); + }); + + it("stores grant identity without its code and retains provider on retry", async () => { + await withTestHarness(async ({ deps }) => { + installProvider(provider()); + const host = upsertHost(deps.db, deps.hub, { name: "test" })!; + const grant = await serverAccess.resolve(deps, { + key: "k", + hostId: host.id, + signal, + }); + expect(grant.headers).toEqual({ "x-access-token": "secret-header" }); + const row = getHost(deps.db, host.id)!; + expect(row.serverAccessProviderId).toBe("connect"); + expect(row.serverAccessGrantId).toBe(host.id); + expect(JSON.stringify(row)).not.toContain("secret-header"); + await expect( + serverAccess.resolve(deps, { + key: "k", + hostId: host.id, + access: { providerId: "direct" }, + signal, + }), + ).rejects.toThrow("different"); + await serverAccess.release(deps, { key: "k", hostId: host.id }); + expect(getHost(deps.db, host.id)?.serverAccessGrantId).toBeNull(); + }); + }); + + it("keeps failed release retryable and refuses invalid grant output without echoing it", async () => { + await withTestHarness(async ({ deps }) => { + const release = vi.fn().mockRejectedValueOnce(new Error("retry")); + installProvider({ ...provider(), release }); + const host = upsertHost(deps.db, deps.hub, { name: "test" })!; + await serverAccess.resolve(deps, { key: "k", hostId: host.id, signal }); + await expect( + serverAccess.release(deps, { key: "k", hostId: host.id }), + ).rejects.toThrow("retry"); + expect(getHost(deps.db, host.id)?.serverAccessGrantId).toBe(host.id); + installProvider({ + ...provider(), + acquire: async () => ({ + id: "id", + serverUrl: "https://secret:secret@example.com", + }), + }); + await expect( + serverAccess.resolve(deps, { key: "k", hostId: host.id, signal }), + ).rejects.toThrow("invalid grant"); + }); + }); + + it("uses the explicit machine URL before the environment fallback", async () => { + vi.stubEnv("BB_EXTERNAL_URL", "https://fallback.example.com"); + await withTestHarness(async ({ deps }) => { + setAppSettings(deps.db, { + ...defaultAppSettings, + machineServerUrl: "https://configured.example.com", + }); + expect(await serverAccessStatus(deps)).toMatchObject({ + effectiveUrl: "https://configured.example.com", + urlSource: "setting", + }); + setAppSettings(deps.db, defaultAppSettings); + expect(await serverAccessStatus(deps)).toMatchObject({ + effectiveUrl: "https://fallback.example.com", + urlSource: "BB_EXTERNAL_URL", + }); + }); + }); +}); + +it("keeps interrupted access visible and releases the acquisition without a returned grant", async () => { + await withTestHarness(async ({ deps }) => { + const message = "Cloud device may need dashboard revocation"; + const release = vi + .fn() + .mockRejectedValueOnce(new Error(message)) + .mockResolvedValue(undefined); + installProvider({ + ...provider(), + acquire: async () => { + throw new Error(message); + }, + release, + }); + const host = upsertHost(deps.db, deps.hub, { name: "interrupted" })!; + deps.db + .insert(machineEnrollments) + .values({ + id: "interrupted-enrollment", + owner: "test", + key: "k", + hostId: host.id, + state: "pending", + createdAt: Date.now(), + updatedAt: Date.now(), + }) + .run(); + 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: "connect", + serverAccessGrantId: null, + teardownMessage: message, + }); + expect( + listPublicHostsWithStatus(deps).find((entry) => entry.id === host.id) + ?.lifecycle.progress, + ).toBe(message); + await expect( + serverAccess.release(deps, { key: "k", hostId: host.id }), + ).rejects.toThrow(message); + expect(getHost(deps.db, host.id)?.serverAccessProviderId).toBe("connect"); + await serverAccess.release(deps, { key: "k", hostId: host.id }); + expect(release).toHaveBeenLastCalledWith({ + key: JSON.stringify(["test", "k"]), + hostId: host.id, + grantId: null, + }); + expect(getHost(deps.db, host.id)?.serverAccessProviderId).toBeNull(); + }); +}); diff --git a/apps/server/test/services/plugins/builtin-plugins.test.ts b/apps/server/test/services/plugins/builtin-plugins.test.ts index 8aa08023ce..c770aed923 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([ + "machine-digitalocean", "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-authoring-docs.test.ts b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts index cfb245f854..92a9d99052 100644 --- a/apps/server/test/services/plugins/plugin-authoring-docs.test.ts +++ b/apps/server/test/services/plugins/plugin-authoring-docs.test.ts @@ -19,6 +19,7 @@ import { type PluginMessageActionContext, type PluginMessageActionRegistration, type PluginMessageDirectiveProps, + type PluginMachineProviderInputsProps, type PluginNavPanelProps, type PluginNavPanelRegistration, type PluginNewThreadPanelProps, @@ -165,6 +166,8 @@ const BB_PLUGIN_API_KEYS = [ "experimental_aiServices", "experimental_hooks", "experimental_environments", + "experimental_machines", + "experimental_serverAccess", "sdk", "onDispose", ] as const satisfies readonly (keyof BbPluginApi)[]; @@ -230,7 +233,9 @@ const THREAD_EVENT_PAYLOAD_FIELDS = { "attemptNumber", ], } as const satisfies { - [E in keyof PluginThreadEventPayloads]: readonly (keyof PluginThreadEventPayloads[E])[]; + [ + E in keyof PluginThreadEventPayloads + ]: readonly (keyof PluginThreadEventPayloads[E])[]; }; type MissingThreadEventField = { @@ -265,6 +270,7 @@ type SlotPropsByName = { experimental_providerIcon: PluginProviderIconRegistration; experimental_timelineRenderer: PluginTimelineRendererProps; experimental_environmentProviderInputs: PluginEnvironmentProviderInputsProps; + experimental_machineProviderInputs: PluginMachineProviderInputsProps; }; type MissingSlot = Exclude; @@ -383,6 +389,12 @@ const FRONTEND_SLOT_PROP_FIELDS = { "value", "onChange", ], + experimental_machineProviderInputs: [ + "projectId", + "value", + "onChange", + "experimental_agentProviderId", + ], } as const satisfies { [S in keyof SlotPropsByName]: readonly (keyof SlotPropsByName[S])[]; }; @@ -507,14 +519,24 @@ describe("bb-plugin-authoring skill", () => { const skillEntry = readFileSync(SKILL_PATH, "utf8"); const skill = readSkillTree(); - it("does not advertise unshipped machine providers", () => { - for (const doc of [ - skillEntry, - readReference("frontend-renderer-slots.md"), - readReference("backend-events.md"), - ]) { - expect(doc).not.toMatch(/machine providers?|custom-machine/); - } + it("documents machine creation checkpoints and private bootstrap delivery", () => { + expect(skillEntry).toContain("machine providers"); + const backend = readReference("backend-machines.md"); + expect(backend).toContain("prepareEnrollment({ key })"); + expect(backend).toContain("await checkpoint(resource)"); + expect(backend).toContain("bb.experimental_machines.bootstrap({"); + expect(backend).toMatch( + /Never put the\s+bootstrap bundle in resource JSON/, + ); + expect(backend.indexOf("prepareEnrollment({ key })")).toBeLessThan( + backend.indexOf("await checkpoint(resource)"), + ); + expect(backend.indexOf("await checkpoint(resource)")).toBeLessThan( + backend.indexOf("bb.experimental_machines.bootstrap({"), + ); + expect(readReference("frontend-renderer-slots.md")).toContain( + "app.slots.experimental_machineProviderInputs", + ); }); it("has frontmatter naming the skill after its directory", () => { @@ -640,7 +662,7 @@ describe("bb-plugin-authoring skill", () => { const backendIndex = readReference("backend-api-index.md"); const appSymbols = [ "experimental_BranchPicker", - "BranchPickerProps", + "ExperimentalBranchPickerProps", "experimental_useBranches", "UseBranchesArgs", "BranchesState", diff --git a/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts b/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts index e6381d36a5..0fa148d972 100644 --- a/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts +++ b/apps/server/test/services/plugins/plugin-dev-build-problems.test.ts @@ -13,6 +13,7 @@ async function createRuntime() { const db = createConnection(":memory:"); migrate(db); return createPluginRuntime({ + machineEnrollments: null, deps: { db, hub: { diff --git a/apps/server/test/services/plugins/plugin-service.test.ts b/apps/server/test/services/plugins/plugin-service.test.ts index e2280ab955..77032a9036 100644 --- a/apps/server/test/services/plugins/plugin-service.test.ts +++ b/apps/server/test/services/plugins/plugin-service.test.ts @@ -1315,7 +1315,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-wire.test.ts b/apps/server/test/services/plugins/plugin-wire.test.ts index 249aab2bb1..756a37cc05 100644 --- a/apps/server/test/services/plugins/plugin-wire.test.ts +++ b/apps/server/test/services/plugins/plugin-wire.test.ts @@ -14,13 +14,14 @@ const BASE = "http://127.0.0.1:3334"; const EVIL_ORIGIN = "https://evil.example"; const WIRE_SOURCE = ` - import { defineRpcContract } from "@get-bb/plugin-sdk"; + import { defineRpcContract, experimental_PluginRpcConflict } from "@get-bb/plugin-sdk"; import { z } from "zod"; const rpcContract = defineRpcContract({ echo: { input: z.object({ x: z.number().optional(), kept: z.boolean().optional() }), output: z.object({ echoed: z.unknown() }), }, + conflict: { input: z.null(), output: z.null() }, boom: { input: z.record(z.string(), z.unknown()), output: z.null() }, publish: { input: z.object({ channel: z.string(), payload: z.unknown() }), @@ -119,6 +120,7 @@ const WIRE_SOURCE = ` }, }), { auth: "none" }); bb.rpc.register(rpcContract, { + conflict: () => { throw new experimental_PluginRpcConflict("Revision changed", 3); }, echo: async (input: any) => ({ echoed: input }), boom: async () => { throw new Error("rpc boom"); @@ -569,6 +571,19 @@ describe("plugin wire surfaces (http/rpc dispatcher + realtime)", () => { }); }); + it("rpc returns HTTP 409 and the latest revision for an explicit conflict", async () => { + const response = await rpc(harness, "conflict", null); + expect(response.status).toBe(409); + expect(await response.json()).toEqual({ + ok: false, + error: { + code: "conflict", + message: "Revision changed", + latestRevision: 3, + }, + }); + }); + it("rpc: happy path, handler error → 500 envelope, unknown method → 404", async () => { const ok = await rpc(harness, "echo", { x: 1 }); expect(ok.status).toBe(200); 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..4471944311 --- /dev/null +++ b/apps/server/test/services/projects/project-source-setup.test.ts @@ -0,0 +1,183 @@ +import { getProjectSourceByHost, projectSourceOwnsPath } from "@bb/db"; +import { describe, expect, it } from "vitest"; +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.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 de3f5000d3..126392b01c 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 19af6fbf6f..6f4c235636 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 f36bb8bc7c..5fb98e1609 100644 --- a/apps/server/test/services/threads/timeline-event-budget.test.ts +++ b/apps/server/test/services/threads/timeline-event-budget.test.ts @@ -38,7 +38,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-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 2287afdd22..160fa7c7eb 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 @@ -49,7 +49,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 3244fe36f7..2a43e06aaa 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 26fd5fe3e3..751cd0eb70 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/machine-provider-rows.test.ts b/apps/server/test/system/machine-provider-rows.test.ts new file mode 100644 index 0000000000..16541b315b --- /dev/null +++ b/apps/server/test/system/machine-provider-rows.test.ts @@ -0,0 +1,200 @@ +import { ensurePersonalProject, setProjectGitRemoteUrlIfMissing } from "@bb/db"; +import { PERSONAL_PROJECT_ID } from "@bb/domain"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, +} from "@get-bb/plugin-sdk/internal/host-policy"; +import { systemMachineProvidersResponseSchema } from "@bb/server-contract"; +import { setPluginEnvironmentProviderBridge } from "../../src/services/plugins/plugin-environment-provider-registry.js"; +import { setPluginMachineProviderBridge } from "../../src/services/plugins/plugin-machine-provider-registry.js"; +import { completeProviderSelection } from "../../src/services/threads/thread-environment-placement.js"; +import { createMachine } from "../../src/services/machines/provider-orchestration.js"; +import { seedHostSession, seedProjectWithSource } from "../helpers/seed.js"; +import { withTestHarness } from "../helpers/test-app.js"; + +const policy = { + idleSuspendMs: null, + retire: { after: "never" as const }, + removeRetryMs: 30_000, +}; +const row = { + displayName: "Test machine", + environmentProviderId: "project-checkout", +}; + +afterEach(() => { + setPluginMachineProviderBridge(undefined); + setPluginEnvironmentProviderBridge(undefined); +}); + +describe("machine checkout picker rows", () => { + it.each(["git-project", "no-remote", "personal", "unscoped"] as const)( + "gates only the checkout row for %s", + async (scope) => { + await withTestHarness(async (harness) => { + const { host } = seedHostSession(harness.deps, { id: "row-host" }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + ensurePersonalProject(harness.db); + if (scope === "git-project") + setProjectGitRemoteUrlIfMissing( + harness.db, + harness.hub, + project.id, + "https://example.test/repo.git", + ); + const environmentRecords = [ + { id: "project-checkout", requires: { projectCheckout: true } }, + { id: "personal-workspace", requires: { projectless: true } }, + ].map(({ id, requires }) => ({ + pluginId: "test-environment", + provider: validatePluginEnvironmentProviderDeclaration({ + id, + displayName: id, + requires, + create: async () => ({ + status: "created", + path: "/workspace", + ownsPath: false, + }), + remove: async () => ({ status: "removed" }), + }), + })); + setPluginEnvironmentProviderBridge({ + listEnvironmentProviders: () => environmentRecords, + getEnvironmentProvider: (id) => + environmentRecords.find((record) => record.provider.id === id), + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + const machineRecord = { + pluginId: "test-machine", + provider: validatePluginMachineProviderDeclaration({ + experimental_reconcileCleanup: async () => ({ status: "removed" }), + id: "test-machine", + displayName: "Test machine", + policy, + environmentRow: row, + inputs: z.object({ size: z.string() }), + create: async () => ({ + status: "created", + hostId: host.id, + resource: {}, + }), + remove: async () => ({ status: "removed" }), + }), + }; + const machineRecords = [ + machineRecord, + { + ...machineRecord, + provider: { + ...machineRecord.provider, + id: "no-shortcut", + environmentRow: null, + }, + }, + { + ...machineRecord, + provider: { + ...machineRecord.provider, + id: "personal-shortcut", + environmentRow: { + displayName: "Personal machine", + environmentProviderId: "personal-workspace", + }, + }, + }, + ]; + setPluginMachineProviderBridge({ + listMachineProviders: () => machineRecords, + getMachineProvider: (id) => + machineRecords.find((record) => record.provider.id === id), + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + const projectId = + scope === "unscoped" + ? undefined + : scope === "personal" + ? PERSONAL_PROJECT_ID + : project.id; + const response = await harness.app.request( + `/api/v1/system/machine-providers${projectId === undefined ? "" : `?projectId=${projectId}`}`, + ); + expect(response.status).toBe(200); + const { providers } = systemMachineProvidersResponseSchema.parse( + await response.json(), + ); + expect(providers).toHaveLength(3); + expect(providers[1]).toMatchObject({ + id: "no-shortcut", + environmentRow: null, + availability: { status: "available" }, + }); + expect(providers[2]).toMatchObject({ + id: "personal-shortcut", + environmentRow: { environmentProviderId: "personal-workspace" }, + availability: { status: "available" }, + }); + expect(providers[0]).toMatchObject({ + requires: { gitRemote: false }, + availability: { status: "available" }, + environmentRow: scope === "git-project" ? row : null, + acceptsEmptyInputs: false, + inputs: { type: "object", properties: { size: { type: "string" } } }, + }); + if (scope === "personal") { + const personal = environmentRecords.find( + (record) => record.provider.id === "personal-workspace", + ); + if (personal === undefined) + throw new Error("Missing personal provider"); + const selection = await completeProviderSelection( + harness.deps, + personal, + PERSONAL_PROJECT_ID, + { + machine: { + type: "new", + machineProviderId: "test-machine", + inputs: { size: "small" }, + }, + inputs: null, + }, + ); + expect(selection.machine).toEqual({ + type: "new", + machineProviderId: "test-machine", + inputs: { size: "small" }, + }); + expect( + await createMachine(harness.deps, { + machineProviderId: "test-machine", + projectId: PERSONAL_PROJECT_ID, + inputs: { size: "small" }, + }), + ).toMatchObject({ id: host.id }); + } + if (scope === "unscoped") { + expect( + await createMachine(harness.deps, { + machineProviderId: "test-machine", + projectId: null, + inputs: { size: "small" }, + }), + ).toMatchObject({ id: host.id }); + } + }); + }, + ); +}); diff --git a/apps/server/test/system/machine-readiness.test.ts b/apps/server/test/system/machine-readiness.test.ts new file mode 100644 index 0000000000..38b94e3e2e --- /dev/null +++ b/apps/server/test/system/machine-readiness.test.ts @@ -0,0 +1,403 @@ +import { + beginMachineRestoreSetup, + runMachineRestoreSetup, +} from "../../src/services/machines/restore-setup.js"; +import { runEnvironmentHook } from "../../src/services/environments/environment-hooks.js"; +import { expect, it } from "vitest"; +import { eq } from "drizzle-orm"; +import { + hosts, + projectSources, + environmentSetupOutcomes, + createEnvironment, + machineLifecycles, + environmentHookOperations, +} from "@bb/db"; +import { + beginEnvironmentSetupOutcome, + finishEnvironmentSetupOutcome, +} from "../../src/services/environments/setup-outcomes.js"; +import { ensureHostReady } from "../../src/services/machines/readiness.js"; +import { setPluginAgentContributions } from "../../src/services/plugins/plugin-agent-contributions.js"; +import { withTestHarness } from "../helpers/test-app.js"; +import { seedHostSession, seedProjectWithSource } from "../helpers/seed.js"; +import { + registerHostRpcResponder, + type HostRpcHandlerResult, +} from "../helpers/host-rpc.js"; + +it("serializes CLI installation and reads fenced core hook outcomes across lockfile, ABI and auth changes", async () => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps); + const { project } = seedProjectWithSource(harness.deps, { + hostId: host.id, + }); + harness.deps.db + .update(hosts) + .set({ machineProviderId: "fixture-machine", resource: {} }) + .where(eq(hosts.id, host.id)) + .run(); + let installed = false; + let hookRuns = 0; + let hookFails = false; + let installCount = 0; + let lock = "a"; + let abi = "linux/x64/node-127"; + let dirty: string[] = []; + let route = true; + let reachable = true; + let observedToken = ""; + let token = "first-token"; + harness.deps.db + .update(projectSources) + .set({ ownsPath: true }) + .where(eq(projectSources.projectId, project.id)) + .run(); + setPluginAgentContributions({ + listSkillRootContributions: () => [], + listAgentTools: () => [], + listInstructionContributions: () => [], + findAgentTool: () => undefined, + invokeAgentTool: async () => ({ success: false, contentItems: [] }), + resolveMention: async () => ({ ok: false, error: "unused" }), + resolveProviderEnvHealth: async () => + route + ? { + label: "Pool", + statusMessage: "Routed", + experimental_probe: { + serverPath: "/pool/check", + headers: { authorization: token }, + }, + } + : null, + resolveProviderEnv: async () => ({ entries: [] }), + }); + registerHostRpcResponder(harness, { + hostId: host.id, + sessionId: session.id, + handle: async ({ command }): Promise => { + switch (command.type) { + case "environment.hook.run": + hookRuns++; + return hookFails + ? { + ok: false, + errorCode: "setup_failed", + errorMessage: "Service failed to start", + } + : { ok: true, result: {} }; + case "environment.hook.cancel": + return { ok: true, result: { status: "terminated" } }; + case "provider.installation.status": + return { + ok: true, + result: { + executableName: "codex", + executablePath: installed ? "/bin/codex" : null, + installed, + installSource: installed ? "npmGlobal" : "notInstalled", + currentVersion: installed ? "1.0.0" : null, + latestVersion: "1.0.0", + minimumSupportedVersion: "1.0.0", + npmPackageName: "codex", + npmGlobalPackageVersion: null, + installAction: installed + ? null + : { kind: "install", label: "Install", command: "install" }, + needsUpdate: false, + versionUnsupported: false, + }, + }; + case "provider.installation.run": + installed = true; + installCount++; + return { + ok: true, + result: { + events: [ + { + type: "completed", + provider: "codex", + exitCode: 0, + signal: null, + success: true, + }, + ], + }, + }; + case "host.readiness.probe": + observedToken = command.headers.authorization!; + return { + ok: true, + result: { reachable, status: reachable ? 200 : 401 }, + }; + case "provider.health": + return { + ok: true, + result: { + supported: true, + health: { + status: "unauthenticated", + statusMessage: null, + accountEmail: null, + planLabel: null, + installedVersion: "1.0.0", + minimumSupportedVersion: "1.0.0", + canInstall: false, + canUpdate: false, + loginCommand: "login", + }, + }, + }; + case "workspace.readiness.inspect": + return { + ok: true, + result: { + commit: "commit", + dirty, + files: [{ path: "package-lock.json", sha256: lock }], + abi, + }, + }; + default: + throw new Error(`Unexpected ${command.type}`); + } + }, + }); + try { + const args = { + hostId: host.id, + projectId: project.id, + providerId: "codex", + threadId: null, + path: "/tmp/test-project", + }; + const ready = () => ensureHostReady(harness.deps, args); + expect(await ready()).toMatchObject({ + status: "blocked", + code: "setup_required", + }); + const identity = { + hostId: host.id, + path: args.path, + operationId: "setup-1", + }; + const recordSetup = async (operationId: string) => { + await beginEnvironmentSetupOutcome(harness.deps, { + ...identity, + operationId, + }); + await finishEnvironmentSetupOutcome(harness.deps, { + ...identity, + operationId, + succeeded: true, + }); + }; + await recordSetup("setup-1"); + expect(await Promise.all([ready(), ready()])).toEqual([ + expect.objectContaining({ status: "ready" }), + expect.objectContaining({ status: "ready" }), + ]); + expect(installCount).toBe(1); + token = "rotated-token"; + expect((await ready()).status).toBe("ready"); + expect(observedToken).toBe(token); + lock = "b"; + dirty = [" M package-lock.json"]; + expect(await ready()).toMatchObject({ + status: "blocked", + code: "dirty_checkout", + }); + dirty = []; + expect(await ready()).toMatchObject({ + status: "blocked", + code: "setup_stale", + }); + await recordSetup("setup-2"); + expect((await ready()).status).toBe("ready"); + abi = "linux/arm64/node-127"; + expect(await ready()).toMatchObject({ + status: "blocked", + code: "setup_stale", + }); + await beginEnvironmentSetupOutcome(harness.deps, { + ...identity, + operationId: "old", + }); + await beginEnvironmentSetupOutcome(harness.deps, { + ...identity, + operationId: "new", + }); + await finishEnvironmentSetupOutcome(harness.deps, { + ...identity, + operationId: "old", + succeeded: true, + }); + expect( + harness.deps.db + .select() + .from(environmentSetupOutcomes) + .where(eq(environmentSetupOutcomes.hostId, host.id)) + .get(), + ).toMatchObject({ operationId: "new", state: "running" }); + expect(await ready()).toMatchObject({ + status: "blocked", + code: "setup_required", + }); + await finishEnvironmentSetupOutcome(harness.deps, { + ...identity, + operationId: "new", + succeeded: false, + }); + expect(await ready()).toMatchObject({ + status: "blocked", + code: "setup_failed", + }); + await recordSetup("setup-3"); + expect((await ready()).status).toBe("ready"); + reachable = false; + expect(await ready()).toMatchObject({ + status: "blocked", + code: "credential_route_unreachable", + }); + route = false; + expect(await ready()).toMatchObject({ + status: "blocked", + code: "credentials_required", + }); + route = true; + reachable = true; + createEnvironment(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + path: args.path, + providerOwnsPath: true, + status: "ready", + environmentProvider: null, + }); + harness.db + .insert(machineLifecycles) + .values({ + hostId: host.id, + observedState: "running", + observedAt: Date.now(), + recoveryState: "healthy", + restoreOperationId: "restored-once", + }) + .run(); + expect( + (await Promise.all([ready(), ready()])).map((x) => x.status), + ).toEqual(["ready", "ready"]); + expect(hookRuns).toBe(1); + expect((await ready()).status).toBe("ready"); + expect(hookRuns).toBe(1); + hookFails = true; + harness.db + .update(machineLifecycles) + .set({ restoreOperationId: "restored-again" }) + .where(eq(machineLifecycles.hostId, host.id)) + .run(); + expect(await ready()).toMatchObject({ + status: "blocked", + code: "setup_failed", + }); + expect(hookRuns).toBe(2); + expect( + harness.db.select().from(environmentHookOperations).all(), + ).toHaveLength(2); + expect(await ready()).toMatchObject({ + status: "blocked", + code: "setup_failed", + }); + expect(hookRuns).toBe(2); + expect( + await ensureHostReady(harness.deps, { + ...args, + threadId: "bypassed-thread", + }), + ).toMatchObject({ + status: "blocked", + code: "credential_route_unavailable", + }); + } finally { + setPluginAgentContributions(undefined); + } + }); +}); + +it("limits restore setup to the persisted generation and does not rerun creation setup for later worktrees", async () => + withTestHarness(async (h) => { + const { host, session } = seedHostSession(h.deps); + const { project } = seedProjectWithSource(h.deps, { hostId: host.id }); + const runs: string[] = []; + registerHostRpcResponder(h, { + hostId: host.id, + sessionId: session.id, + handle: async ({ command }): Promise => { + if (command.type === "workspace.readiness.inspect") + return { + ok: true, + result: { + commit: "a".repeat(40), + dirty: [], + files: [], + abi: "linux/x64/node-127", + }, + }; + if (command.type === "environment.hook.run") { + runs.push(command.path); + return { ok: true, result: {} }; + } + throw new Error(`Unexpected RPC ${command.type}`); + }, + }); + const original = createEnvironment(h.db, h.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/old-checkout", + providerOwnsPath: true, + status: "ready", + }); + h.db + .insert(machineLifecycles) + .values({ + hostId: host.id, + observedState: "running", + observedAt: Date.now(), + recoveryState: "healthy", + }) + .run(); + beginMachineRestoreSetup(h.deps, host.id, "earlier-resume"); + const pending = h.db.select().from(machineLifecycles).get(); + expect(pending?.restoreCheckouts).toEqual([ + { id: original.id, path: "/tmp/old-checkout" }, + ]); + await runEnvironmentHook(h.deps, { + id: "new-environment-normal-setup", + hostId: host.id, + path: "/tmp/new-worktree", + kind: "setup", + resumeOnly: false, + report: { step() {}, log() {} }, + signal: AbortSignal.timeout(10_000), + }); + createEnvironment(h.db, h.hub, { + projectId: project.id, + hostId: host.id, + path: "/tmp/new-worktree", + providerOwnsPath: true, + status: "ready", + }); + await Promise.all([ + runMachineRestoreSetup(h.deps, host.id), + runMachineRestoreSetup(h.deps, host.id), + ]); + expect(runs).toEqual(["/tmp/new-worktree", "/tmp/old-checkout"]); + expect(h.db.select().from(machineLifecycles).get()).toMatchObject({ + restoreOperationId: null, + restoreCheckouts: null, + }); + await runMachineRestoreSetup(h.deps, host.id); + expect(runs).toHaveLength(2); + })); diff --git a/apps/server/test/threads/environment-providers.test.ts b/apps/server/test/threads/environment-providers.test.ts index f43c0eb6e5..f9a37d49f5 100644 --- a/apps/server/test/threads/environment-providers.test.ts +++ b/apps/server/test/threads/environment-providers.test.ts @@ -1,5 +1,8 @@ import { createDeferredPromise } from "@bb/test-helpers"; import { resolveGitCheckoutAvailability } from "../../src/services/environments/provider-availability.js"; +import * as gitCredentials from "../../src/services/machines/git-credentials.js"; +import { answerMachineReadiness } from "../helpers/machine-readiness.js"; +import { advanceThreadProvisioning } from "../../src/services/threads/thread-provisioning.js"; import { providerOperations, type TestEnvironmentProviderContext, @@ -9,34 +12,52 @@ import { createEnvironment, createProjectSource, ensurePersonalProject, - getEnvironment, getEnvironmentLaunch, + getEnvironment, + getHost, + getMachineLaunch, + upsertMachineLaunch, + updateHost, getDefaultProjectSource, + getProjectSourceByHost, + setProjectGitRemoteUrlIfMissing, getThread, listEnvironments, listEvents, } from "@bb/db"; -import { PERSONAL_PROJECT_ID, type JsonValue } from "@bb/domain"; +import { + encodeClientTurnRequestIdNumber, + PERSONAL_PROJECT_ID, + type JsonValue, +} from "@bb/domain"; import type { PluginDispatchEnvironmentIntent, PluginEnvironmentProviderDeclaration, + PluginMachineProviderDeclaration, PluginEnvironmentValidateDecision, 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 { afterEach, describe, expect, it, vi } from "vitest"; +import { + validatePluginEnvironmentProviderDeclaration, + validatePluginMachineProviderDeclaration, +} from "@get-bb/plugin-sdk/internal/host-policy"; +import { beforeEach, afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { ApiError } from "../../src/errors.js"; +import { persistPendingProviderRequest } from "../../src/services/environments/provider-orchestration.js"; import { + listEnvironmentProviders, requestEnvironmentProviderRecheck, setPluginEnvironmentProviderBridge, 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 { setPluginThreadEventEmitter } from "../../src/services/plugins/plugin-thread-events.js"; import { attemptDispatch } from "../../src/services/threads/dispatch-attempt.js"; import { recheckEnvironmentProviderLaunches, @@ -47,7 +68,14 @@ import { forgetAllActiveThreadProvisionContexts, getActiveThreadProvisionContext, } from "../../src/services/threads/thread-provisioning-active-context.js"; -import { registerTestHostRpcCapture } from "../helpers/commands.js"; +import { createMetadataPendingContext } from "../../src/services/threads/thread-provisioning-context.js"; +import { + listQueuedThreadCommands, + listQueuedCommands, + waitForQueuedCommand, + reportQueuedCommandSuccess, + registerTestHostRpcCapture, +} from "../helpers/commands.js"; import { readJson } from "../helpers/json.js"; import { textInput } from "../helpers/prompt-input.js"; import { @@ -56,6 +84,7 @@ import { seedPrimaryHost, seedProjectWithSource, seedThread, + seedThreadRuntimeState, } from "../helpers/seed.js"; import { withTestHarness, type TestAppHarness } from "../helpers/test-app.js"; @@ -154,9 +183,15 @@ function installEnvironmentIntentProbe(): PluginDispatchEnvironmentIntent[] { return environmentIntents; } +beforeEach(() => { + vi.spyOn(gitCredentials, "resolveGitCredentials").mockResolvedValue([]); +}); + afterEach(() => { + vi.restoreAllMocks(); forgetAllActiveThreadProvisionContexts(); setPluginEnvironmentProviderBridge(undefined); + setPluginMachineProviderBridge(undefined); setPluginHookProvider(undefined); }); @@ -1463,3 +1498,917 @@ describe("environment provider listing", () => { }); }); }); + +describe("machine and environment provider composition", () => { + it.each(["missing", "existing", "personal-workspace"] as const)( + "composes a new machine with %s project source state", + async (sourceState) => { + await withTestHarness(async (harness) => { + const { host, session } = seedHostSession(harness.deps, { + id: "host-provider-composition", + }); + const original = seedHostSession(harness.deps, { + id: "host-original-source", + }); + const { project } = seedProjectWithSource(harness.deps, { + hostId: original.host.id, + path: "/original/project", + }); + const remoteUrl = "https://example.test/team/project.git"; + const environmentProviderId = + sourceState === "personal-workspace" + ? "personal-workspace" + : "project-checkout"; + if (sourceState !== "personal-workspace") { + setProjectGitRemoteUrlIfMissing( + harness.db, + harness.hub, + project.id, + remoteUrl, + ); + } + if (sourceState === "existing") { + createProjectSource(harness.db, harness.hub, { + projectId: project.id, + hostId: host.id, + type: "local_path", + path: WORKSPACE_PATH, + }); + } + registerTestHostRpcCapture(harness, { + hostId: host.id, + sessionId: session.id, + }); + const machineProvider = validatePluginMachineProviderDeclaration({ + id: "test-machine", + displayName: "Test machine", + environmentRow: { + displayName: "Test machine", + environmentProviderId: "project-checkout", + }, + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 30_000, + }, + experimental_reconcileCleanup: async () => ({ status: "removed" }), + create: async ({ key }) => ({ + status: "created", + hostId: host.id, + resource: { key }, + }), + remove: async () => ({ status: "removed" }), + } satisfies PluginMachineProviderDeclaration); + const machineRecord = { + pluginId: "test-machine-plugin", + provider: machineProvider, + }; + setPluginMachineProviderBridge({ + listMachineProviders: () => [machineRecord], + getMachineProvider: (id) => + id === machineProvider.id ? machineRecord : undefined, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + const checkoutContexts: TestEnvironmentProviderContext[] = []; + const worktreeContexts: TestEnvironmentProviderContext[] = []; + installTargets([ + { + id: environmentProviderId, + requiresProjectCheckout: sourceState !== "personal-workspace", + provision: (context) => { + checkoutContexts.push(context); + return { + action: "ready", + environment: { + type: "host", + hostId: host.id, + path: context.projectCheckout?.path ?? "/personal/workspace", + ownsPath: false, + }, + }; + }, + }, + { + id: "git-worktree", + requiresProjectCheckout: true, + requiresGitCheckout: true, + inputs: z.object({ + branch: z.object({ kind: z.literal("default") }), + }), + provision: (context) => { + worktreeContexts.push(context); + return { + action: "ready", + environment: { + type: "host", + hostId: host.id, + path: "/tmp/provider-composition-worktree", + }, + }; + }, + }, + ]); + + const checkoutThread = await createThreadFromRequest(harness.deps, { + environment: { + type: "provider", + environmentProviderId, + machine: { + type: "new", + machineProviderId: "test-machine", + inputs: null, + }, + inputs: null, + }, + input: textInput("Create the machine"), + origin: "app", + projectId: project.id, + providerId: "codex", + model: "requested-model", + startedOnBehalfOf: null, + }); + if (sourceState === "missing") { + const defaultPath = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone_default_path", + 3_000, + ); + expect(defaultPath.command).toEqual({ + type: "project.clone_default_path", + projectSlug: `project-${project.id}`, + }); + await reportQueuedCommandSuccess(harness, defaultPath, { + path: WORKSPACE_PATH, + }); + const exists = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "host.paths_exist", + ); + await reportQueuedCommandSuccess(harness, exists, { + existence: { [WORKSPACE_PATH]: false }, + }); + const clone = await waitForQueuedCommand( + harness, + ({ command }) => command.type === "project.clone", + 3_000, + ); + expect(checkoutContexts).toEqual([]); + expect( + getProjectSourceByHost(harness.db, project.id, host.id), + ).toBeNull(); + expect(clone.row.hostId).toBe(host.id); + expect(clone.command).toEqual({ + type: "project.clone", + contributedEnv: [], + remoteUrl, + projectSlug: project.name, + targetPath: WORKSPACE_PATH, + }); + await reportQueuedCommandSuccess(harness, clone, { + path: WORKSPACE_PATH, + gitRemoteUrl: remoteUrl, + }); + } + await vi.waitFor( + () => { + expect( + getThread(harness.db, checkoutThread.id)?.environmentId, + ).not.toBeNull(); + }, + { timeout: 3_000 }, + ); + expect( + getEnvironment( + harness.db, + getThread(harness.db, checkoutThread.id)?.environmentId ?? "", + )?.status, + ).toBe("ready"); + await answerMachineReadiness(harness); + const start = await waitForQueuedCommand( + harness, + ({ command }) => + command.type === "thread.start" && + command.threadId === checkoutThread.id, + ); + await reportQueuedCommandSuccess(harness, start, { + providerThreadId: "provider-composition-thread", + }); + expect(getThread(harness.db, checkoutThread.id)?.status).toBe("active"); + if (sourceState === "personal-workspace") { + expect(checkoutContexts).toHaveLength(1); + expect(checkoutContexts[0]?.projectCheckout).toBeNull(); + expect( + getProjectSourceByHost(harness.db, project.id, host.id), + ).toBeNull(); + expect(listQueuedCommands(harness, "project.clone")).toEqual([]); + expect( + getEnvironment( + harness.db, + getThread(harness.db, checkoutThread.id)?.environmentId ?? "", + )?.path, + ).toBe("/personal/workspace"); + return; + } + expect(checkoutContexts).toHaveLength(1); + expect(checkoutContexts[0]?.projectCheckout).toEqual({ + path: WORKSPACE_PATH, + experimental_ownsPath: sourceState === "missing", + }); + expect( + getProjectSourceByHost(harness.db, project.id, host.id), + ).toMatchObject({ path: WORKSPACE_PATH }); + expect(listQueuedCommands(harness, "project.clone")).toEqual([]); + expect(getMachineLaunch(harness.db, checkoutThread.id)).toMatchObject({ + phase: "ready", + hostId: host.id, + }); + expect(getHost(harness.db, host.id)).toMatchObject({ + machineProviderId: "test-machine", + }); + const checkoutEnvironment = getEnvironment( + harness.db, + getThread(harness.db, checkoutThread.id)?.environmentId ?? "", + ); + expect(checkoutEnvironment?.environmentProviderSelection).toEqual({ + machine: { + type: "new", + machineProviderId: "test-machine", + inputs: null, + }, + inputs: null, + }); + + const worktreeThread = await createThreadFromRequest(harness.deps, { + environment: { + type: "provider", + environmentProviderId: "git-worktree", + machine: { type: "existing", hostId: host.id }, + inputs: { branch: { kind: "default" } }, + }, + input: textInput("Create a worktree"), + origin: "app", + projectId: project.id, + providerId: "codex", + model: "requested-model", + startedOnBehalfOf: null, + }); + await vi.waitFor( + () => { + expect( + getThread(harness.db, worktreeThread.id)?.environmentId, + ).not.toBeNull(); + }, + { timeout: 3_000 }, + ); + expect(worktreeContexts).toHaveLength(1); + expect(worktreeContexts[0]?.host.id).toBe(host.id); + expect( + getEnvironment( + harness.db, + getThread(harness.db, worktreeThread.id)?.environmentId ?? "", + )?.hostId, + ).toBe(host.id); + }); + }, + ); +}); + +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( + getActiveThreadProvisionContext(created.id)?.request.environmentIntent, + ).toEqual({ + type: "provider", + environmentProviderId: "git-worktree", + machine: { type: "existing", hostId: host.id }, + inputs: { branch: { kind: "named", name: "main" } }, + selectionResolved: false, + produced: null, + }); + 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" }, + }); + 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?.environmentProviderId).toBe(PROVIDER_ID); + expect(environment?.environmentProviderSelection).toEqual({ + machine: { type: "existing", hostId: host.id }, + inputs: { image: "img", cpus: 2 }, + }); + expect(environment?.environmentProviderInstanceKey).toBe(created.id); + expect(environment?.providerOwnsPath).toBe(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, session } = seedTargetFixture( + harness, + "host-target-attached", + ); + 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-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.managed).toBe(false); + expect(response.workspaceProvisionType).toBeNull(); + }); + }); + + 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" }, + }); + 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, session } = seedTargetFixture( + harness, + "host-target-no-key", + ); + registerTestHostRpcCapture(harness, { + hostId: host.id, + sessionId: session.id, + }); + 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.each(["existing", "new"] as const)( + "reprovisions a destroyed environment using its %s machine selection", + async (machineType) => { + await withTestHarness(async (harness) => { + const asks: TestEnvironmentProviderContext[] = []; + const { host, project } = seedTargetFixture( + harness, + "host-target-reask", + ); + const replacementHost = + machineType === "new" + ? seedHostSession(harness.deps, { + id: "host-target-new-generation", + }).host + : host; + const machineKeys: string[] = []; + if (machineType === "new") { + updateHost(harness.db, harness.hub, host.id, { + destroyedAt: Date.now(), + phase: "destroyed", + }); + const machineRecord = { + pluginId: "replacement-machine", + provider: validatePluginMachineProviderDeclaration({ + id: "replacement-machine", + displayName: "Replacement machine", + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 30_000, + }, + experimental_reconcileCleanup: async () => ({ + status: "removed", + }), + create: async ({ key }) => { + machineKeys.push(key); + return { + status: "created", + hostId: replacementHost.id, + resource: { key }, + }; + }, + remove: async () => ({ status: "removed" }), + }), + }; + setPluginMachineProviderBridge({ + listMachineProviders: () => [machineRecord], + getMachineProvider: (id) => + id === "replacement-machine" ? machineRecord : undefined, + invokeProvider: async (_pluginId, _label, run) => ({ + ok: true, + value: await run(), + }), + decisionTimeoutMs: 10_000, + }); + } + const replacement = seedEnvironment(harness.deps, { + environmentProviderPluginId: PLUGIN_ID, + environmentProviderId: PROVIDER_ID, + hostId: replacementHost.id, + path: "/tmp/environment-providers-replacement", + projectId: project.id, + }); + const gone = createEnvironment(harness.db, harness.hub, { + providerOwnsPath: false, + hostId: host.id, + projectId: project.id, + path: "/tmp/environment-providers-gone", + status: "destroyed", + environmentProvider: { + pluginId: PLUGIN_ID, + environmentProviderId: PROVIDER_ID, + instanceKey: null, + selection: { + machine: + machineType === "new" + ? { + type: "new", + machineProviderId: "replacement-machine", + inputs: null, + } + : { type: "existing", hostId: host.id }, + inputs: { image: "img", cpus: 2 }, + }, + }, + }); + const thread = seedThread(harness.deps, { + environmentId: gone.id, + projectId: project.id, + status: "idle", + }); + if (machineType === "new") { + upsertMachineLaunch(harness.db, { + key: thread.id, + providerId: "replacement-machine", + projectId: project.id, + inputs: null, + attempt: 1, + phase: "ready", + startedAt: Date.now(), + failedAt: null, + failure: null, + message: null, + transientFailures: 0, + hostId: host.id, + resource: { key: thread.id }, + stepText: "Ready", + pendingLog: "", + cancelPending: false, + }); + } + seedThreadRuntimeState(harness.deps, { + environmentId: gone.id, + providerThreadId: "provider-reask", + threadId: thread.id, + }); + installTarget({ + inputs: CONTAINER_INPUTS, + provision: (context) => { + asks.push(context); + return { + action: "ready", + environment: { + type: "host", + hostId: replacement.hostId, + path: replacement.path!, + }, + }; + }, + }); + + const outcome = await attemptDispatch(harness.deps, { + thread, + payload: { input: textInput("Keep going"), mode: "start" }, + source: { kind: "inline" }, + queuePayload: { kind: "inline" }, + origin: null, + originPluginId: null, + startedOnBehalfOf: null, + trigger: "user", + }); + expect(outcome.kind).toBe("dispatched"); + await vi.waitFor( + () => { + expect(getThread(harness.db, thread.id)?.environmentId).toBe( + replacement.id, + ); + }, + { timeout: 3_000 }, + ); + if (machineType === "new") { + const replacementKey = `${thread.id}:replacement:${host.id}`; + expect(machineKeys).toEqual([replacementKey]); + expect(getMachineLaunch(harness.db, thread.id)).toMatchObject({ + phase: "ready", + hostId: host.id, + attempt: 1, + }); + expect(getMachineLaunch(harness.db, replacementKey)).toMatchObject({ + phase: "ready", + hostId: replacementHost.id, + attempt: 1, + }); + } + expect(asks).toHaveLength(1); + expect(asks[0]?.environment?.id).toBe(gone.id); + expect(asks[0]?.inputs).toEqual({ image: "img", cpus: 2 }); + expect(getThread(harness.db, thread.id)?.status).toBe("starting"); + }); + }, + ); + + 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 (context) => { + cancelled.push(context.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); + + recheckEnvironmentProviderLaunches(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({ + 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 () => + 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(getEnvironmentLaunch(harness.db, created.id)?.phase).toBe( + "creating", + ), + ); + const attempt = getEnvironmentLaunch(harness.db, created.id)?.attempt; + const storedRequest = getEnvironmentLaunch(harness.db, created.id)?.request; + expect(storedRequest).not.toBeNull(); + forgetAllActiveThreadProvisionContexts(); + ready = true; + await advanceThreadProvisioning(harness.deps, { threadId: created.id }); + await vi.waitFor(() => + expect(getEnvironmentLaunch(harness.db, created.id)?.phase).toBe("ready"), + ); + await advanceThreadProvisioning(harness.deps, { threadId: created.id }); + await vi.waitFor(() => + expect(getThread(harness.db, created.id)?.environmentId).not.toBeNull(), + ); + expect(getEnvironmentLaunch(harness.db, created.id)?.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 () => + withTestHarness(async (harness) => { + installTarget(null); + const { host, project } = seedTargetFixture( + harness, + "host-register-later", + { environmentProviderId: PROVIDER_ID }, + ); + const thread = seedThread(harness.deps, { + projectId: project.id, + status: "starting", + }); + const context = createMetadataPendingContext({ + clientRequestId: encodeClientTurnRequestIdNumber({ value: 1 }), + environmentIntent: { + type: "provider", + environmentProviderId: PROVIDER_ID, + machine: { type: "existing", hostId: host.id }, + inputs: null, + selectionResolved: true, + produced: null, + }, + execution: { + model: "requested-model", + serviceTier: "default", + reasoningLevel: "medium", + permissionMode: "full", + source: "client/turn/requested", + }, + fork: null, + input: textInput("Do the thing"), + seedWithoutRun: false, + titleProvided: true, + }); + persistPendingProviderRequest(harness.db, thread.id, context.request); + await advanceThreadProvisioning(harness.deps, { threadId: thread.id }); + await vi.waitFor(() => + expect(getEnvironmentLaunch(harness.db, thread.id)?.attempt).toBe(0), + ); + forgetAllActiveThreadProvisionContexts(); + installTarget({ provision: () => readyAt(host) }); + await advanceThreadProvisioning(harness.deps, { threadId: thread.id }); + await vi.waitFor( + () => + expect({ + thread: getThread(harness.db, thread.id), + launch: getEnvironmentLaunch(harness.db, thread.id), + events: provisioningEvents(harness, thread.id), + }).toMatchObject({ thread: { environmentId: expect.any(String) } }), + { timeout: 2000 }, + ); + expect(getEnvironmentLaunch(harness.db, thread.id)?.attempt).toBe(1); + })); diff --git a/apps/server/test/threads/thread-create-helpers.test.ts b/apps/server/test/threads/thread-create-helpers.test.ts index 97cd22b457..f7a34dedb6 100644 --- a/apps/server/test/threads/thread-create-helpers.test.ts +++ b/apps/server/test/threads/thread-create-helpers.test.ts @@ -114,7 +114,6 @@ describe("createThreadRecord", () => { const deps = { db, hub: noopNotifier }; const host = upsertHost(db, noopNotifier, { name: "Test Host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "Test Project", diff --git a/apps/server/test/threads/thread-data.test.ts b/apps/server/test/threads/thread-data.test.ts index 7f1432b208..a950f6649b 100644 --- a/apps/server/test/threads/thread-data.test.ts +++ b/apps/server/test/threads/thread-data.test.ts @@ -15,7 +15,6 @@ function setup() { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/threads/thread-parent.test.ts b/apps/server/test/threads/thread-parent.test.ts index 2a2650c16f..def2bfefe0 100644 --- a/apps/server/test/threads/thread-parent.test.ts +++ b/apps/server/test/threads/thread-parent.test.ts @@ -21,7 +21,6 @@ function setup() { migrate(db); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", diff --git a/apps/server/test/threads/thread-provisioning-state.test.ts b/apps/server/test/threads/thread-provisioning-state.test.ts index b8f36e9cd3..10e1c9d823 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/docs/api_to_audit.md b/docs/api_to_audit.md index d5db382c58..a13da82d19 100644 --- a/docs/api_to_audit.md +++ b/docs/api_to_audit.md @@ -559,6 +559,112 @@ 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. +## `bb.experimental_machines` (`register`) + +**Additional consumer (PR 2a).** Tailscale adopts existing devices through +`create`, the input slot/RPC and the enrolment/bootstrap helpers; it also +registers private direct server-access grants. It adds no public SDK member +or daemon wire field. Generic discover/adopt ownership and endpoint migration +remain open; the additional consumer does not stabilize these APIs. + +**What it does.** Lets a plugin create and own execution machines. A machine +provider declares its id, display name, optional icon, optional git-remote requirement, +optional Standard Schema inputs, availability, validation, optional picker +sugar row, lifecycle policy, and idempotent create/remove operations. Create is +keyed durably and may enrol a project checkout, but standalone machine creation +passes a nullable project and does not require a checkout. The returned JSON +resource is private to the provider and capped at 16 KiB. + +Core persists launch attempts and machine lifecycle state on hosts. It suspends +an idle machine only when the provider declares both suspend and resume; a +provider without that pair must set `idleSuspendMs: null`. Sending work resumes +a suspended machine. Create must prepare enrollment before awaiting +`PluginMachineProviderCreateContext.checkpoint(resource)` to persist an allocation +on its launch. Cancellation removes this resource without waiting for enrollment, +and records removal before retrying access revocation. The bootstrap bundle must +never appear in resource JSON. Suspend can persist a recoverable resource before +destructive cleanup with `checkpoint`. The experimental namespace carries the +stability signal; supporting public types and declaration members stay +unprefixed. A last-thread policy +retires after its grace period and removes every environment through its own +provider before removing the machine; a never policy retires only on explicit +user removal. Inputs are +persisted in `hosts.machine_provider_selection` and readable by every plugin. +They must never contain secrets: credentials belong in plugin settings and +inputs carry non-secret references such as a target name. +For a new-machine environment request, after connection core reuses project +source setup to clone the project's Git remote and register a source if the +selected environment provider requires `projectCheckout` and the host lacks a +source. Machine plugins do not clone projects. Existing sources and providers +without that requirement, including personal workspace, bypass setup. Audit fresh +hosts, clone failures, retries, and personal-workspace-first creation. Automatic +setup serializes per project/host and uses a stable project-ID target, inspecting +an existing target for the expected remote before registering it after a crash. +Refuse mismatched targets without overwriting them. This adds +no plugin API and does not change standalone machine creation. +When `icon` is omitted, Machines and Add machine show no provider logo or +provider badge, so the machine has the same presentation as a manually +enrolled machine. + +**Audit before stabilizing.** Verify create-key ownership and retry timing, +crash recovery when enrolment completed before create returned, cancellation +cleanup, resource privacy, removal serialization, and lifecycle progress +presentation. Confirm the requirement vocabulary is sufficient for future +remote and VM providers, the picker-sugar coupling to one environment provider, +and whether policies need per-machine overrides before dropping the prefix. + +## `@get-bb/plugin-sdk/machine-provider` + +**What it does.** Exports the typed contexts and results used by +`bb.experimental_machines.register`: availability and validation, idempotent +create with project/gitRemote/inputs/key/attempt/checkpoint/report/signal, optional paired +suspend/resume, remove, environment-row sugar, and retirement policy. + +Supporting root exports `PluginMachineProviderDeclaration`, +`PluginMachineProviderRequirements`, and `PluginMachineValidateDecision` are +covered by this audit alongside these machine-provider subpath exports: +`PluginMachineProviderDefinition`, +`PluginMachineProviderInputsSchema`, +`PluginMachineProviderPolicy`, +`PluginMachineProviderEnvironmentRow`, +`PluginMachineProviderAvailabilityContext`, +`PluginMachineProviderAvailability`, +`PluginMachineProviderValidateContext`, +`PluginMachineProviderCreateContext`, +`PluginMachineProviderCreateResult`, +`PluginMachineProviderLifecycleContext`, +`PluginMachineProviderSuspendContext`, +`PluginMachineProviderProgress`, +`PluginMachineProviderResourceResult`, +`PluginMachineProviderRemoveResult`. + +**Audit before stabilizing.** Audit the same lifecycle, retry, privacy, and +composition questions as `bb.experimental_machines`, whether an omitted icon +should continue to suppress provider branding, plus whether suspend and +resume need distinct result unions beyond an updated private resource. Audit +both `PluginMachineProviderCreateContext.checkpoint` and +`PluginMachineProviderSuspendContext.checkpoint` durability: cancellation before +enrollment, a crash after allocation, same-key recovery without duplicate +resources, and access-revocation retries after successful vendor removal. + +## `app.slots.experimental_machineProviderInputs` (`@get-bb/plugin-sdk/app`) + +Supporting app exports are `PluginMachineProviderInputsRegistration`, +`PluginMachineProviderInputsProps`, and `PluginMachineProviderInputsChange`. + +**What it does.** Registers the app control for one machine provider's inputs +with `{ machineProviderId, component }`. The component receives +`{ projectId, value, onChange, experimental_agentProviderId? }` and reports ready JSON or a blocked reason. The experimental agent field is the selected composer provider, null outside the composer, and may be omitted by older hosts. Image controls must validate verification for this selected agent before reporting ready. Stabilization requires coverage of provider changes and project switches without stale launch inputs. +It is used by the picker sugar row and Add machine flow. The resulting value is +persisted and readable by every plugin, so it must contain no secrets; +credentials belong in plugin settings and the value should carry only +non-secret configuration or credential references. + +**Audit before stabilizing.** Confirm the shared control suits both composer +and standalone machine creation, ready/blocked is sufficient, schema validation +belongs only at the server boundary, and missing-control behavior matches +environment provider inputs. + ## `bb.branding.experimental_icons` (manifest) and namespaced presentation glyphs **What it does.** A plugin ships SVG files and declares a name → file map in @@ -1697,13 +1803,13 @@ while a palette switch resolves, so a consumer never paints an unthemed frame. ## `app.slots.experimental_providerIcon` (`@get-bb/plugin-sdk/app`) **Kept experimental (2026-08-22).** zero shipped registrations — first-party -agent and environment providers use declared glyphs or SVG assets, +agent, environment, and machine providers use declared glyphs or SVG assets, and the provider catalogs' `glyph` / `logoUrl` icon metadata covers both forms without a frontend bundle; the open questions are id squatting and whether the slot should exist at all (deleting it is the owner's call). **What it does.** Lets a plugin frontend supply the React component bb draws -as one agent or environment provider's icon: `{ providerId, icon }`, +as one agent, environment, or machine provider's icon: `{ providerId, icon }`, where `icon` receives only the host's `className` (sizing; agent providers also have the declared `strings.iconTint`). The component wins over the provider's served `logoUrl`, which the host otherwise draws as a `currentColor` mask. @@ -2529,3 +2635,211 @@ and portable output handling for host-local plugin commands on every supported O is released by attachment or completed cancellation cleanup, including after failure. Stabilize after restart, cancellation, competing checkout, and path-reservation behavior has been audited. + +## `ServerAccessProviderDeclaration.experimental_attention` + +Optional sync/async hook returning a deliberate user-safe diagnostic or null. +Core fills null for providers without the hook and exposes `attention` in system +configuration; General → Machine access displays it independently of availability. +Use durable state for diagnostics that must survive plugin reload. Never return +credentials or raw provider payloads. Stabilization requires verifying reload, +cleared diagnostics, multiple providers, and no impact on provider selection. + +## `bb.experimental_serverAccess.register` + +`PluginServerAccess` registers server access through `ServerAccessProviderDeclaration`: id, +displayName, availability, acquire({ key, hostId, signal }) and +release({ key, hostId, grantId }). `ServerAccessGrant` carries `{ id, serverUrl, headers?: Record }`. +Machines attach these optional headers to enrollment, HTTP, WebSocket and runtime +requests. A direct grant omits headers; access providers own credential redemption. +`ServerAccessSelection` selects a provider explicitly. Core persists only +provider id and grant id per host; credentials travel in bootstrap delivery. +Direct access reads General's machineServerUrl with BB_EXTERNAL_URL fallback. +General's defaultMachineAccess selects a provider; automatic prefers paired +Connect, then an available direct URL. 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. + +An Error named `experimental_ServerAccessRecoveryError` exposes its deliberate +user-safe recovery message through the plugin boundary; ordinary errors stay +redacted. Release receives a null grantId when acquire was interrupted. Core persists the +provider before acquisition and retries release by key and hostId. Keep intent +and credential-bearing grants in secret storage; only non-secret revocation +metadata belongs in KV. Delivered v1 bundles upgrade locally to v2 headers in the +CLI and installer, including one-time legacy Connect redemption. + +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.enrollments` exposes `prepare`, `waitForConnection`, and `cancel`; `prepareEnrollment` and `waitForConnection` also compose with `installerCommand` and `bootstrap`. Enrollment keys are scoped to the calling plugin and permanently retain their host identity. Pending credentials are single-use, short-lived, and encrypted at rest with a private server key; preparation after expiry reissues them, while an unexpired bundle survives a server restart. Bootstrap v2 replaces `client` with optional `headers`. Pending encrypted v1 bundles are upgraded on preparation by reacquiring access, retaining the host and unspent enrollment credential, then persisting v2. A successful exchange is recovered as `enrolled` after a server crash. Cancelling an enrolled identity preserves its durable credentials and runtime access. + +`MachineExecutor` carries argv, stdin, a timeout, and an abort signal. `installerCommand` returns argv plus private stdin; callers must transport stdin without logging or persisting it in machine resources. `bootstrap` ignores remote output and reports fixed progress messages. It starts enrolled machines again so snapshot restores can reuse their identity. Preinstalled mode requires a compatible `bb` and `bb-app`; install mode requires Node, npm, and curl and installs no OS packages. + +Stabilization requires independent Modal and SSH consumers, failure verification for expired credentials, concurrent retries, interrupted exchange, cancellation, identity mismatch, and restored snapshots, plus an audit that credentials never enter resource data or logs. Migration and live vendor verification remain part of the integration release gate. + +The bootstrap surface's supporting exports are `EnrollmentBootstrap`, +`MachineEnrollment`, `MachineExecutorRequest`, `MachineExecutor`, +`MachineEnrollmentRequest`, `MachineConnectionRequest`, `MachineEnrollments`, +`MachineBootstrapRequest`, `MachineInstallerCommand`, and `MachineBootstrapApi`. +They belong to experimental `PluginMachines`; their unprefixed names do not +indicate stabilization. `installerCommand` is synchronous. Executor `writeFile` +is optional; bootstrap uses `exec`. Create must prepare enrollment, persist an +allocated resource with `await checkpoint(resource)`, then bootstrap with the +same key. Never checkpoint a bootstrap bundle. 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 `experimental_reconcileCleanup` + +Required reconciliation-only cancellation callback on `PluginMachineProviderDefinition`. +Core supplies the durable launch key, progress reporter and a cleanup signal. +Providers discover and remove uncertain allocations using a persisted submission intent +and 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 removeRetryMs 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 resume allocation checkpoint + +`PluginMachineProviderResumeContext.checkpoint(resource): Promise` on the +experimental machine-provider contract persists a bounded allocation recovery +record before bootstrap. 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. + +Definitive pre-allocation rejection uses `PluginMachineProviderCreateResult` +with `status: "failed"` and `allocation: "none"`. Absence means the allocation +outcome is unknown. Core skips vendor reconciliation only for that explicit +result and still settles enrollment, access and the pending host. Providers +must persist the rejection before returning so restart cannot allocate twice. +Unknown-allocation reconciliation stops after a 30-minute launch window; +unresolved cleanup remains recorded. Known resources and access release keep +retrying at removeRetryMs indefinitely. An explicit cancel retries cleanup even +after automatic retries are exhausted. Stabilization requires distinguishing +definitive vendor rejection from transport timeouts and ambiguous submissions. + +## Machine dev-box policy and inventory + +`PluginMachineProviderDefinition.experimental_idleSuspendMs({hostId, resource})` +resolves a nullable per-machine timeout. Core retains dispatch exclusion, busy +thread/terminal checks, a durable idle baseline (including empty machines), and +retirement policy. Stabilize after testing provider reloads, empty boxes, terminals, +and wake-on-dispatch across providers. + +`PluginMachineProviderDefinition.experimental_details({hostId, resource, signal})` +returns `{summary, values}` inventory for machine rows/details and +`bb.sdk.hosts.experimental_providerDetails({hostId, signal})`. The server parses +JSON at the provider boundary; the callback must honor cancellation and avoid +secrets. `bb machine show --json` includes `providerDetails`. Stabilize after +reviewing cost freshness, unavailable providers and bounded output on large +accounts. No daemon wire fields change. + +## Transient manual enrollment command + +`@bb/sdk` exposes `hosts.experimental_enrollmentCommand({ id, scope?, signal? })`, backed +by `GET /hosts/launches/:id/enrollment-command`. Authorized host-management +followers receive `{ command: string | null }` with `Cache-Control: no-store`. +Machine-gated callers are rejected. The server decrypts the pending bundle on +demand, checks provider ownership, launch state, expiry and unused credentials, +and returns null after exchange, cancellation or connection. Clients must keep +it in transient view state, never progress events, logs, persisted query caches +or transcripts. No new plugin registration contract was introduced. + +Stabilization requires authorization, settlement races, no-store and credential +non-persistence coverage, plus a provider-neutral transient-action contract if +other machine providers need this interaction. + +The enrollment command scope defaults to `launch`, which reads only that exact +launch. With `scope: "thread"`, `id` identifies a thread and the server resolves +its current launch through the machine replacement history on every request. +Consumed original launches remain unavailable through launch scope. The thread +picker uses thread scope; machine creation and CLI follow use launch scope. + +## `PluginCliResult.experimental_continue`, `PluginCliExecutionResult.experimental_continue`, `experimental_PluginCliContinuation`, and `experimental_PluginRpcConflict` + +`experimental_continue: {argv, delayMs}` asks the invoking CLI to print the +current bounded response and request the next page, waiting at most 60 seconds. +Interrupting the client stops reading without cancelling a durable job. +`experimental_PluginRpcConflict(message, latestRevision)` returns a typed +`conflict` error and HTTP 409; `latestRevision` is null for a key conflict. + +Before stabilization, verify interruption and reconnect behavior across remote +CLIs, validate continuation bounds at both boundaries, and confirm revision and +idempotency conflicts need the same error shape. These surfaces do not add +server/daemon wire fields. + +## Machine readiness and pinned workspace setup + +`hosts.experimental_ensureReady({hostId,providerId,projectId})` is the SDK twin of +`bb machine ready`. It returns `{status:"ready",checks}` or +`{status:"blocked",code,stage,message,retryable}`. Core checks CLI compatibility +through the registered installer, validates credential routing from the machine, +and checks the recorded checkout setup outcome. Provider-managed turns use the same barrier. +The server contract exports `experimental_hostReadinessRequestSchema`, +`experimental_hostReadinessResponseSchema`, `experimental_HostReadinessRequest` +and `experimental_HostReadinessResponse` for these same validated shapes. Their +stabilization follows the readiness audit below. + +The provider context's `projectCheckout.experimental_ownsPath` identifies a +checkout materialised by core; absence from older servers means unowned. Core +supplies an explicit boolean, derived from persisted source ownership, never +from caller inputs. Project checkout reports ownsPath only for that exact path, +so core's environment hook policy applies to fresh machine clones and leaves +user-maintained attachments alone. Readiness consumes core hook outcomes rather +than executing setup. Audit ownership propagation and recovery before stabilizing. + +`ExperimentalPluginProviderEnvHealthContext.experimental_readiness` requests a +fresh thread-aware check (null threadId means a standalone readiness request). +`ExperimentalPluginProviderEnvHealth.experimental_probe` contains a server-relative +path and private headers for a bounded authenticated probe from the machine. +Core never includes these headers in readiness responses or durable stamps. + +Before stabilization: audit first dispatch and resumed turns, concurrent install/ +setup, unavailable installers, route rotation/bypass and reachability, dirty +checkout protection, missing dependencies, ABI/lockfile changes, private clone +failure, and parity between public CLI/SDK and plugin/provider-managed dispatch. + +## Machine deadline observation and effective lifecycle policy + +`PluginMachineProviderDefinition.experimental_observe({hostId,resource,signal})` +reads vendor state and expiry without allocating or changing identity. It returns +`{state:running|suspended|missing|unknown,expiresAt,resource}`. UTC deadlines are +milliseconds; null means no vendor deadline. `experimental_policy({hostId,resource})` +returns current `{idleSuspendMs,retireAfterMs,deadlineLeadMs}`, with null disabling +each policy. Core persists observations, retention and a fenced maintenance lease. +Suspend's `checkpoint(resource, experimental_snapshotAt?)` records a successful +filesystem save time before destructive cleanup. Allocation checkpoints are not saves. + +`hosts.experimental_lifecycle({hostId,keep?})` returns phase, expiresAt, +maintenanceAt, lastSnapshotAt, recoveryState, message, retentionAt and keep. +Omitting keep is read-only; true prevents automatic retention removal and false +restores it. Explicit machine removal remains available. CLI parity is +`bb machine lifecycle MACHINE [--keep|--no-keep] --json`. + +The corresponding server contract schemas and types are +`experimental_hostLifecycleRequestSchema`, `experimental_hostLifecycleResponseSchema`, +`experimental_HostLifecycleRequest` and `experimental_HostLifecycleResponse`. They +share the same lifecycle behavior and stabilization criteria. + +Stabilization requires controlled-clock restart, lease, dispatch, loss and retention +coverage, vendor deadline reconciliation, snapshot-before-terminate evidence, and +review of recoverable failures and account changes. Planned rotation cannot protect +against a server outage spanning vendor expiry without independent storage/watchdogs. + +Restore setup hooks use the same recorded core hook path as creation and receive the shared core machine environment contributions. Hook output redacts contributed secrets across stream boundaries. PR 2 protocol 193 supplies the shared hook environment and stream redaction. Modal introduced protocol 194 because it adds `workspace.readiness.inspect` and `host.readiness.probe` requests and responses; a protocol 193 daemon cannot execute those readiness commands. Enrolled machines update before use. Failed restore hooks block readiness. + +Machine readiness now uses protocol 195: `workspace.readiness.inspect` also returns non-Git directory fingerprints (canonical path and setup-hook content hash), and `provider.health` optionally receives the effective turn environment. Health checks with contributions run in disposable isolated provider processes; rotated credentials do not reuse a prior maintenance process. Successful legacy core hooks are reconciled by two stable, clean inspections without executing setup again. diff --git a/docs/cli-guide-and-skill.md b/docs/cli-guide-and-skill.md index ed193a5d8b..6e64c948d3 100644 --- a/docs/cli-guide-and-skill.md +++ b/docs/cli-guide-and-skill.md @@ -14,3 +14,5 @@ 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 lets users select a project before reviewing provider inputs; the selected project is passed to the same `hosts.submit`/`bb machine create --project` launch surface. diff --git a/docs/configuration.md b/docs/configuration.md index 043d957501..93885a649a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -555,8 +555,15 @@ 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 Existing machine (the built-in `manual` provider) +alongside installed cloud, SSH and Tailscale providers. Existing machine prints +a private enrollment command and waits for the daemon. `bb machine create +--provider manual` follows the same lifecycle; `--no-wait` returns the launch ID +and command. Manual machines never suspend or retire automatically. Removal +revokes access; run `bb machine 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 @@ -1167,3 +1174,91 @@ The Browser Automation plugin supports desktop attachment and headless Chrome on On each selected browser host, the plugin's host worker installs that release automatically on first use under `/runtime/npm/`, using the host's `npm` with scripts disabled, verifying the registry signature and SLSA provenance, downloading the matching GitHub release binary, and checking its digest before launch. Later sessions reuse the verified install without network access. Headless mode discovers installed Chrome/Chromium or uses `/runtime/chrome`. These files belong to the plugin host storage directory; they are not paths on the server or invoking agent host, and the user's global npm installation is never modified. No runtime sandbox-disabling setting is provided. For isolated development smoke tests only, `DEV_BROWSER_SMOKE_BINARY` selects the absolute binary path for the runtime smoke, `DEV_BROWSER_SMOKE_CHROME` selects the absolute Chrome path, and `DEV_BROWSER_SMOKE_NO_SANDBOX=1` enables the fixture's no-sandbox wrapper where the test host requires it. The `smoke:install` task performs a real install of the pinned release into a disposable directory. These variables do not change normal plugin runtime behavior. + +### Machine server access + +General 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`. General displays the effective URL and its source; it does +not claim the remote machine can reach it. An unset access provider selects +paired Connect, otherwise direct when a URL exists. 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 and redacts secrets from +progress and errors. Machine selection and precedence stay in the server +resolver, which returns no contributions for the local host. + +Settings → General → Machine environment defines variables for all enrolled +machine hosts. Local hosts do not receive them. Add plain values or mark a value +secret; secrets use core's 0600 secret files and are never returned by settings +reads. `GH_TOKEN` is always secret. Notes are public metadata. + +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 [--secret] [--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()`, +`sdk.system.setMachineEnvironment({ name, value, secret, note })`, and +`sdk.system.unsetMachineEnvironment(name)`. Secret list rows have `value: null`. +`bb settings show --json` exposes the built-in readiness as `machineGit`. + +### DigitalOcean dev boxes + +The `machine-digitalocean` plugin's `DIGITALOCEAN_TOKEN` is a secret setting. +`snapshotRetention` defaults to 2 (valid range 1–100) for newly created boxes. +Machine creation inputs accept `idleMinutes` (1–43200, null disables, default +null). Existing boxes use `bb digitalocean configure ''` +or the plugin's Dev boxes settings. Configuration contains `idleMinutes`, +`retention`, and `schedule` (null or weekdays 0–6, sleep/wake HH:mm and IANA +timezone). Saving replaces configuration and resets the schedule cursor to now. +Core owns idle/dispatch/retirement; DigitalOcean retirement remains never. +Powered-off droplets still bill; snapshot storage bills per GB. Official pricing: +https://docs.digitalocean.com/products/droplets/details/pricing/ and +https://docs.digitalocean.com/products/snapshots/details/pricing/ . diff --git a/docs/worktrees.md b/docs/worktrees.md index 96c7efe8f4..e07e5c4e95 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. + +After a provider restores a machine filesystem, core reruns `.bb-env-setup.sh` for each owned checkout using the recorded hook path. Make the hook idempotent and use it to restart project services; processes are not restored with filesystem snapshots. A failed hook blocks readiness. Fresh machine clones do not apply `.worktreeinclude`; supply local files and secrets through Machine environment settings. + +On machines, readiness revalidates successful setup records after an upgrade without executing the setup hook again. Git checkouts use commit, lockfile, hook and ABI fingerprints; owned Personal directories use their canonical path and setup-hook content hash. A dirty legacy checkout requires review before readiness can adopt its earlier successful hook. diff --git a/packages/agent-runtime/src/integration.env-isolation.test.ts b/packages/agent-runtime/src/integration.env-isolation.test.ts index 699b81bbef..c3a1706cf6 100644 --- a/packages/agent-runtime/src/integration.env-isolation.test.ts +++ b/packages/agent-runtime/src/integration.env-isolation.test.ts @@ -185,3 +185,61 @@ 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", + secret: true, + 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-provider-process.ts b/packages/agent-runtime/src/runtime-provider-process.ts index e7aaa0132d..dd6a000a85 100644 --- a/packages/agent-runtime/src/runtime-provider-process.ts +++ b/packages/agent-runtime/src/runtime-provider-process.ts @@ -1,7 +1,9 @@ +import { StringDecoder } from "node:string_decoder"; import type { ChildProcess } from "node:child_process"; import { randomUUID } from "node:crypto"; import { join } from "node:path"; import { + createSecretStreamRedactor, sanitizeInheritedChildProcessEnv, killProcessGroup, spawnPortablePipedProcess, @@ -62,6 +64,7 @@ interface RuntimeProviderProcessManagerArgs { ) => RuntimeProviderIdentityState; env: Record | undefined; getNextRequestId: () => number; + getSecrets?: () => readonly string[]; handleStdoutLine: (args: RuntimeProviderProcessLineArgs) => void; onProcessExit: AgentRuntimeOptions["onProcessExit"]; onProviderThreadDetached: (threadId: string) => void; @@ -429,6 +432,10 @@ export class RuntimeProviderProcessManager { }, }); + const stderrRedactor = createSecretStreamRedactor( + this.args.getSecrets ?? [], + ); + const stderrDecoder = new StringDecoder("utf8"); child.stderr.on("data", (chunk: Buffer) => { if ( this.shuttingDown || @@ -437,12 +444,19 @@ export class RuntimeProviderProcessManager { return; } consumeProviderStderrChunk({ - chunk, + chunk: Buffer.from(stderrRedactor.push(stderrDecoder.write(chunk))), onLine: this.args.onStderr, providerProcess, }); }); child.stderr.on("end", () => { + consumeProviderStderrChunk({ + chunk: Buffer.from( + stderrRedactor.push(stderrDecoder.end()) + stderrRedactor.flush(), + ), + onLine: this.args.onStderr, + providerProcess, + }); if ( this.shuttingDown || !this.isCurrentProviderProcess({ providerProcess }) || diff --git a/packages/agent-runtime/src/runtime.lifecycle.test.ts b/packages/agent-runtime/src/runtime.lifecycle.test.ts index afeac5b6a8..a1602e9e79 100644 --- a/packages/agent-runtime/src/runtime.lifecycle.test.ts +++ b/packages/agent-runtime/src/runtime.lifecycle.test.ts @@ -333,6 +333,84 @@ describe("createAgentRuntime lifecycle", () => { await runtime.shutdown(); }); + it("reinjects rotated machine credentials on the next turn and resume without exposing them in events", 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", + secret: true, + }, + ]; + 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: "[redacted]", + 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)).not.toContain("first-git-token"); + expect(JSON.stringify(events)).not.toContain("rotated-git-token"); + expect(JSON.stringify(events)).not.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: { masked: true }, + }), + ]), + }), + ]), + ); + } finally { + await runtime.shutdown(); + } + }); + it("drops unresolved server paths without preventing thread start", async () => { const record = createScriptedEchoRequestRecord(); const events: ThreadEvent[] = []; diff --git a/packages/agent-runtime/src/runtime.ts b/packages/agent-runtime/src/runtime.ts index 3d64e4d7c8..f959143440 100644 --- a/packages/agent-runtime/src/runtime.ts +++ b/packages/agent-runtime/src/runtime.ts @@ -1,10 +1,12 @@ import path from "node:path"; import { z } from "zod"; +import { createSecretStreamRedactor } from "@bb/process-utils"; import { normalizeProviderThreadNameEvent, toProviderExternalThreadName, } from "@bb/domain"; import type { DynamicTool, InstructionMode, ThreadEvent } from "@bb/domain"; +import { createThreadEventStreamRedactor } from "./thread-event-stream-redaction.js"; import type { AdapterCommand } from "./provider-adapter.js"; import { BRIDGE_JSON_RPC_ERRORS, @@ -249,6 +251,35 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { let nextRequestId = 1; const threadIdentityRegistry = new RuntimeThreadIdentityRegistry(); const threadRuntimeConfigs = new Map(); + const threadSecrets = new Map>(); + function rememberSecrets( + threadId: string, + entries: readonly AgentRuntimeContributedEnvEntry[], + ): void { + let values = threadSecrets.get(threadId); + if (!values) { + values = new Set(); + threadSecrets.set(threadId, values); + } + for (const entry of entries) { + if (entry.secret && typeof entry.value === "string" && entry.value) + values.add(entry.value); + } + } + function getSecrets(): string[] { + return [...threadSecrets.values()].flatMap((values) => [...values]); + } + const eventRedactor = createThreadEventStreamRedactor(getSecrets); + function redactSecrets(text: string): string { + const redactor = createSecretStreamRedactor(getSecrets); + return redactor.push(text) + redactor.flush(); + } + function reportStderr( + ...[text, context]: Parameters> + ): void { + options.onStderr?.(redactSecrets(text), context); + } + const rateLimitedRetryDelaysMs = options.rateLimitRetry?.delaysMs ?? DEFAULT_RATE_LIMITED_RETRY_DELAYS_MS; const threadCreationRequestTimeoutMs = @@ -311,6 +342,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { threadIdentityRegistry.createProviderState({ providerId }), env: options.env, getNextRequestId: () => nextRequestId++, + getSecrets, handleStdoutLine: (args) => handleStdoutLine(args.line, args.providerProcess), onProcessExit: options.onProcessExit, @@ -321,7 +353,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { backgroundWorkState.clearThread(threadId); threadEventGrammar.clearThread(threadId); }, - onStderr: options.onStderr, + onStderr: reportStderr, skillRoots, workspacePath: options.workspacePath, }); @@ -374,7 +406,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { timeoutMs: FAILED_CONSTRUCTION_RELEASE_TIMEOUT_MS, }); } catch (error) { - options.onStderr?.( + reportStderr( `Best-effort release of thread "${args.threadId}" after a failed session construction did not complete: ${error instanceof Error ? error.message : String(error)}`, args.threadId, ); @@ -402,7 +434,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { try { await releaseIdleProviderProcess(args.proc); } catch (shutdownError) { - options.onStderr?.( + reportStderr( `Failed to retire the provider after thread "${args.threadId}" session construction failed: ${shutdownError instanceof Error ? shutdownError.message : String(shutdownError)}`, ); } @@ -536,7 +568,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { args: RetryableRequestArgs, ): Promise { const { error, recovery } = args; - options.onStderr?.( + reportStderr( `Session "${recovery.providerThreadId}" is archived; unarchiving before retrying thread "${recovery.threadId}".`, ); let retryProc: ProviderProcess; @@ -571,7 +603,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { let lastError = args.error; let lastHint = args.hint; for (const retryDelayMs of rateLimitedRetryDelaysMs) { - options.onStderr?.( + reportStderr( `Provider "${args.recovery.providerId}" is rate limited; retrying thread "${args.recovery.threadId}" in ${retryDelayMs}ms.`, ); await delay(retryDelayMs); @@ -677,6 +709,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { threadId: string, config: ThreadRuntimeConfig, ): void { + rememberSecrets(threadId, config.contributedEnv); threadRuntimeConfigs.set(threadId, config); } @@ -694,12 +727,14 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { } function clearThreadRuntimeConfig(threadId: string): void { + for (const event of eventRedactor.flush(threadId)) options.onEvent(event); threadsAwaitingBridgeRestart.delete(threadId); threadsRetryingBridgeRestartOnIdle.delete(threadId); idleProviderSessionSinceMsByThreadId.delete(threadId); pendingTurnStarts.delete(threadId); threadGoalState.clearThread(threadId); threadRuntimeConfigs.delete(threadId); + threadSecrets.delete(threadId); } function beginThreadOperation(threadId: string): void { @@ -927,7 +962,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { }); }, }).catch((error: unknown) => { - options.onStderr?.( + reportStderr( `Bridge restart for thread "${args.threadId}" failed: ${error instanceof Error ? error.message : String(error)}`, args.threadId, ); @@ -964,7 +999,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { backgroundWorkState.hasOpenThreadWork(threadId), ); if (busyThreadId !== undefined) { - options.onStderr?.( + reportStderr( `Deferring the "${currentConfig.providerId}" bridge restart recommended for thread "${args.threadId}": thread "${busyThreadId}" is mid-turn or has open background work on the same process.`, args.threadId, ); @@ -980,7 +1015,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { ? [{ config, providerThreadId: hostedProviderThreadId, threadId }] : []; }); - options.onStderr?.( + reportStderr( `Restarting the "${currentConfig.providerId}" bridge for thread "${args.threadId}": ${hint.message}`, args.threadId, ); @@ -1013,7 +1048,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { threadId: hosted.threadId, }); } catch (error) { - options.onStderr?.( + reportStderr( `Failed to resume thread "${hosted.threadId}" after the bridge restart: ${error instanceof Error ? error.message : String(error)}`, hosted.threadId, ); @@ -1216,7 +1251,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { }); if (!resolvedBbThreadId) { - options.onStderr?.( + reportStderr( `Dropping unscoped provider event ${event.type}; no bb thread could be resolved`, ); continue; @@ -1235,18 +1270,31 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { const grammarResult = threadEventGrammar.observe(stampedEvent); if (grammarResult.kind === "violation") { - options.onStderr?.( + reportStderr( `Dropping ${stampedEvent.type} from provider "${args.proc.providerId}" in thread "${targetThreadId}" (${grammarResult.rule}): ${grammarResult.reason}.`, ); continue; } - const normalizedEvent = normalizeProviderThreadNameEvent(stampedEvent); - turnState.observe(normalizedEvent); - backgroundWorkState.observe(normalizedEvent); - observeProviderSessionIdleState(normalizedEvent); - options.onEvent(normalizedEvent); - threadGoalState.observe(normalizedEvent); + let redactedEvents: ThreadEvent[]; + try { + redactedEvents = eventRedactor.push( + normalizeProviderThreadNameEvent(stampedEvent), + ); + } catch { + reportStderr( + "Provider event redaction failed; event was dropped.", + targetThreadId, + ); + continue; + } + for (const normalizedEvent of redactedEvents) { + turnState.observe(normalizedEvent); + backgroundWorkState.observe(normalizedEvent); + observeProviderSessionIdleState(normalizedEvent); + options.onEvent(normalizedEvent); + threadGoalState.observe(normalizedEvent); + } } } @@ -1264,7 +1312,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { recoveryHint.threadId !== undefined && !args.proc.identity.threadIds.has(recoveryHint.threadId) ) { - options.onStderr?.( + reportStderr( `Dropping provider/recovery ${recoveryHint.kind} from "${args.proc.providerId}": it names thread "${recoveryHint.threadId}", which that process does not host.`, ); return; @@ -1289,7 +1337,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { parsedLine.kind === "non_json" || parsedLine.kind === "invalid_json_rpc" ) { - options.onStderr?.(line); + reportStderr(line); return; } @@ -1421,7 +1469,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { prepared, PREPARED_THREAD_REWIND_RETRY_MS, ); - options.onStderr?.( + reportStderr( `Failed to discard staged rewind ${leaseId}; retrying: ${error instanceof Error ? error.message : String(error)}`, ); return; @@ -1435,7 +1483,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { try { await releaseIdleProviderProcess(proc); } catch (error) { - options.onStderr?.( + reportStderr( `Failed to stop the idle provider after discarding staged rewind ${leaseId}: ${error instanceof Error ? error.message : String(error)}`, ); } @@ -1752,7 +1800,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { providerThreadIdForCleanup, ); } catch (error) { - options.onStderr?.( + reportStderr( `Failed to discard unretained staged rewind ${leaseId}: ${error instanceof Error ? error.message : String(error)}`, ); } @@ -1988,6 +2036,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { watchdogFired: false, }); markProviderSessionNotIdle(threadId); + rememberSecrets(threadId, resolvedContributedEnv); try { await sendCommand({ proc, @@ -2045,7 +2094,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { const activeTurnId = turnState.getActiveTurnId(threadId); if (activeTurnId !== expectedTurnId) { - options.onStderr?.( + reportStderr( `Ignoring stale steer for thread "${threadId}" on turn "${expectedTurnId}"; active turn is ${activeTurnId ?? "none"}.`, ); return { @@ -2101,6 +2150,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { plan: proc.adapter.buildCommandPlan(adapterCommand), providerId: pid, }); + rememberSecrets(threadId, resolvedContributedEnv); try { await sendCommand({ proc, @@ -2131,7 +2181,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { error instanceof JsonRpcResponseError && error.recovery?.kind === "staleTurn" ) { - options.onStderr?.( + reportStderr( `Dropping stale steer for thread "${threadId}": ${error.recovery.message}`, threadId, ); @@ -2466,7 +2516,7 @@ export function createAgentRuntime(options: AgentRuntimeOptions): AgentRuntime { try { await runtime.stopThread({ threadId: candidate.threadId }); } catch (error) { - options.onStderr?.( + reportStderr( `Provider session release failed for ${candidate.threadId}: ${ error instanceof Error ? error.message : String(error) }`, diff --git a/packages/agent-runtime/src/test/runtime-test-harness.ts b/packages/agent-runtime/src/test/runtime-test-harness.ts index 8a32a9151b..8cd39d5cbb 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; archivedSession?: boolean; unarchiveFails?: boolean; @@ -68,6 +69,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-event-redaction.ts b/packages/agent-runtime/src/thread-event-redaction.ts new file mode 100644 index 0000000000..ab5f981979 --- /dev/null +++ b/packages/agent-runtime/src/thread-event-redaction.ts @@ -0,0 +1,238 @@ +import type { JsonValue, ThreadEvent, ThreadEventItem } from "@bb/domain"; + +type Redact = (text: string) => string; + +function redactJson(value: JsonValue, redact: Redact): JsonValue { + if (typeof value === "string") return redact(value); + if (Array.isArray(value)) + return value.map((entry) => redactJson(entry, redact)); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + redactJson(entry, redact), + ]), + ); + } + return value; +} + +function redactOpaque(value: unknown, redact: Redact): unknown { + if (typeof value === "string") return redact(value); + if (Array.isArray(value)) + return value.map((entry: unknown) => redactOpaque(entry, redact)); + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + redactOpaque(entry, redact), + ]), + ); + } + return value; +} + +function redactItem(item: T, redact: Redact): T { + const optional = (value: string | undefined) => + value === undefined ? undefined : redact(value); + const nullable = (value: string | null) => + value === null ? null : redact(value); + if ("presentation" in item && item.presentation) { + const presentation = item.presentation; + item = { + ...item, + presentation: { + ...presentation, + label: { + pending: redact(presentation.label.pending), + completed: redact(presentation.label.completed), + }, + title: optional(presentation.title), + detail: optional(presentation.detail)?.slice(0, 280), + ...(presentation.badge + ? { + badge: { + ...presentation.badge, + label: redact(presentation.badge.label).slice(0, 80), + hint: redact(presentation.badge.hint).slice(0, 80), + }, + } + : {}), + }, + }; + } + switch (item.type) { + case "agentMessage": + case "plan": + return { ...item, text: redact(item.text) }; + case "userMessage": + return { + ...item, + content: item.content.map((entry) => + entry.type === "text" + ? { ...entry, text: redact(entry.text) } + : entry, + ), + }; + case "commandExecution": + return { + ...item, + command: redact(item.command), + aggregatedOutput: optional(item.aggregatedOutput), + }; + case "reasoning": + return { + ...item, + summary: item.summary.map(redact), + content: item.content.map(redact), + }; + case "fileChange": + return { + ...item, + changes: item.changes.map((change) => ({ + ...change, + diff: optional(change.diff), + })), + }; + case "toolCall": + return { + ...item, + arguments: + item.arguments === undefined + ? undefined + : Object.fromEntries( + Object.entries(item.arguments).map(([key, value]) => [ + key, + redactOpaque(value, redact), + ]), + ), + result: redactOpaque(item.result, redact), + error: optional(item.error), + }; + case "webSearch": + return { + ...item, + queries: item.queries.map(redact), + resultText: nullable(item.resultText), + }; + case "webFetch": + return { + ...item, + url: redact(item.url), + prompt: nullable(item.prompt), + pattern: nullable(item.pattern), + resultText: nullable(item.resultText), + }; + case "fileRead": + return { ...item, cmd: optional(item.cmd) }; + case "search": + return { ...item, query: redact(item.query), cmd: optional(item.cmd) }; + case "planSteps": + return { + ...item, + steps: item.steps.map((step) => ({ ...step, step: redact(step.step) })), + explanation: optional(item.explanation), + }; + case "backgroundTask": + return { + ...item, + description: redact(item.description), + summary: optional(item.summary), + error: optional(item.error), + }; + case "delegation": + return { + ...item, + label: redact(item.label), + summary: optional(item.summary), + }; + case "extension": + return { ...item, payload: redactJson(item.payload, redact) }; + default: + return item; + } +} + +export function redactThreadEventContent( + event: T, + redact: Redact, +): T { + const optional = (value: string | undefined) => + value === undefined ? undefined : redact(value); + switch (event.type) { + case "item/started": + case "item/completed": + case "item/backgroundTask/progress": + case "item/backgroundTask/completed": + case "item/delegation/progress": + case "item/delegation/completed": + return { ...event, item: redactItem(event.item, redact) }; + case "item/agentMessage/delta": + case "item/commandExecution/outputDelta": + case "item/fileChange/outputDelta": + case "item/reasoning/summaryTextDelta": + case "item/reasoning/textDelta": + case "item/plan/delta": + return { ...event, delta: redact(event.delta) }; + case "turn/completed": + return { + ...event, + ...(event.error + ? { error: { ...event.error, message: redact(event.error.message) } } + : {}), + }; + case "thread/name/updated": + return { ...event, threadName: redact(event.threadName) }; + case "thread/goal/updated": + return { ...event, objective: redact(event.objective) }; + case "item/mcpToolCall/progress": + case "item/toolCall/progress": + return { ...event, message: optional(event.message) }; + case "turn/plan/updated": + return { + ...event, + plan: event.plan.map((step) => ({ ...step, step: redact(step.step) })), + explanation: optional(event.explanation), + }; + case "turn/diff/updated": + return { ...event, diff: optional(event.diff) }; + case "provider/error": + return { + ...event, + message: redact(event.message), + detail: optional(event.detail), + }; + case "provider/warning": + return { + ...event, + summary: optional(event.summary), + details: optional(event.details), + }; + case "provider/modelFallback": + return { ...event, message: redact(event.message) }; + case "provider.env-resolved": + return { + ...event, + entries: event.entries.map((entry) => ({ + ...entry, + value: + typeof entry.value === "string" ? redact(entry.value) : entry.value, + reason: optional(entry.reason), + })), + }; + case "thread/extensionState/updated": + return { ...event, payload: redactJson(event.payload, redact) }; + case "provider/unhandled": + return { + ...event, + rawEvent: { + ...event.rawEvent, + ...(event.rawEvent.params === undefined + ? {} + : { params: redactJson(event.rawEvent.params, redact) }), + }, + }; + default: + return event; + } +} diff --git a/packages/agent-runtime/src/thread-event-stream-redaction.test.ts b/packages/agent-runtime/src/thread-event-stream-redaction.test.ts new file mode 100644 index 0000000000..08603615c2 --- /dev/null +++ b/packages/agent-runtime/src/thread-event-stream-redaction.test.ts @@ -0,0 +1,96 @@ +import { expect, it } from "vitest"; +import { threadEventSchema, type ThreadEvent } from "@bb/domain"; +import { createThreadEventStreamRedactor } from "./thread-event-stream-redaction.js"; + +function delta( + text: string, + itemId = "item", + channel: + | "item/agentMessage/delta" + | "item/commandExecution/outputDelta" = "item/agentMessage/delta", +): Extract { + return { + type: channel, + threadId: "thread", + providerThreadId: "provider", + itemId, + delta: text, + scope: { kind: "turn", turnId: "turn" }, + }; +} + +it("keeps separate text and command streams and flushes prefixes before cancellation", () => { + const redactor = createThreadEventStreamRedactor(() => [ + "ghp_FAKE_REVIEW_TOKEN", + ]); + expect(redactor.push(delta("ghp_FAK"))).toEqual([]); + expect(redactor.push(delta("safe", "other"))).toEqual([ + delta("safe", "other"), + ]); + expect( + redactor.push( + delta("ghp_FAK", "command", "item/commandExecution/outputDelta"), + ), + ).toEqual([]); + expect(redactor.push(delta("E_REVIEW_TOKEN"))).toEqual([delta("[redacted]")]); + expect( + redactor.push( + delta("E_REVIEW_TOKEN", "command", "item/commandExecution/outputDelta"), + ), + ).toEqual([ + delta("[redacted]", "command", "item/commandExecution/outputDelta"), + ]); + expect(redactor.push(delta("ghp_FAK"))).toEqual([]); + const end: ThreadEvent = { + type: "turn/completed", + threadId: "thread", + providerThreadId: "provider", + status: "interrupted", + scope: { kind: "turn", turnId: "turn" }, + }; + expect(redactor.push(end)).toEqual([delta("[redacted]"), end]); +}); + +it("preserves command reset semantics when flushing an unfinished prefix", () => { + const redactor = createThreadEventStreamRedactor(() => ["secret"]); + const event: ThreadEvent = { + ...delta("safe sec", "command", "item/commandExecution/outputDelta"), + type: "item/commandExecution/outputDelta", + reset: true, + itemId: "command", + delta: "safe sec", + }; + expect(redactor.push(event)).toEqual([{ ...event, delta: "safe " }]); + expect(redactor.flush("thread")).toEqual([ + { ...event, delta: "[redacted]", reset: false }, + ]); +}); + +it("keeps short-secret matches out of structural fields and nested content keys", () => { + const redactor = createThreadEventStreamRedactor(() => ["type", "completed"]); + const event: ThreadEvent = { + type: "item/completed", + threadId: "type", + providerThreadId: "completed", + scope: { kind: "turn", turnId: "type" }, + item: { + type: "toolCall", + id: "type", + tool: "type", + status: "completed", + arguments: { type: "type" }, + result: { type: "completed" }, + }, + }; + const result = redactor.push(event)[0]; + expect(threadEventSchema.safeParse(result).success).toBe(true); + expect(result).toEqual({ + ...event, + item: { + ...event.item, + arguments: { type: "[redacted]" }, + result: { type: "[redacted]" }, + error: undefined, + }, + }); +}); diff --git a/packages/agent-runtime/src/thread-event-stream-redaction.ts b/packages/agent-runtime/src/thread-event-stream-redaction.ts new file mode 100644 index 0000000000..1fb054a196 --- /dev/null +++ b/packages/agent-runtime/src/thread-event-stream-redaction.ts @@ -0,0 +1,89 @@ +import type { ThreadEvent } from "@bb/domain"; +import { createSecretStreamRedactor } from "@bb/process-utils"; +import { redactThreadEventContent } from "./thread-event-redaction.js"; + +type DeltaEvent = Extract; + +export function createThreadEventStreamRedactor( + getSecrets: () => readonly string[], +) { + const streams = new Map< + string, + { + event: DeltaEvent; + redactor: ReturnType; + } + >(); + function flush( + threadId: string, + itemId?: string, + turnId?: string, + ): ThreadEvent[] { + const events: ThreadEvent[] = []; + for (const [key, stream] of streams) { + if ( + stream.event.threadId !== threadId || + (turnId !== undefined && + (stream.event.scope.kind !== "turn" || + stream.event.scope.turnId !== turnId)) || + (itemId !== undefined && stream.event.itemId !== itemId) + ) + continue; + const delta = stream.redactor.flush(); + if (delta) events.push({ ...stream.event, delta }); + streams.delete(key); + } + return events; + } + return { + flush, + push(event: ThreadEvent): ThreadEvent[] { + if ("delta" in event && "itemId" in event) { + const key = JSON.stringify([ + event.threadId, + event.providerThreadId, + event.itemId, + event.type, + event.scope, + event.parentToolCallId, + ]); + if (event.type === "item/commandExecution/outputDelta" && event.reset) + streams.delete(key); + let stream = streams.get(key); + if (!stream) { + stream = { event, redactor: createSecretStreamRedactor(getSecrets) }; + streams.set(key, stream); + } + stream.event = + event.type === "item/commandExecution/outputDelta" + ? { ...event, reset: false } + : event; + const delta = stream.redactor.push(event.delta); + return delta || + (event.type === "item/commandExecution/outputDelta" && event.reset) + ? [{ ...event, delta }] + : []; + } + const flushed = + event.type === "item/completed" + ? flush( + event.threadId, + event.item.id, + event.scope.kind === "turn" ? event.scope.turnId : undefined, + ) + : event.type === "turn/completed" + ? flush( + event.threadId, + undefined, + event.scope.kind === "turn" ? event.scope.turnId : undefined, + ) + : []; + const secrets = getSecrets(); + const safe = redactThreadEventContent(event, (text) => { + const redactor = createSecretStreamRedactor(secrets); + return redactor.push(text) + redactor.flush(); + }); + return [...flushed, safe]; + }, + }; +} diff --git a/packages/agent-runtime/src/thread-shell-environment.ts b/packages/agent-runtime/src/thread-shell-environment.ts index c6aedd5b41..afa5dfc8ca 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; } diff --git a/packages/agent-runtime/src/types.ts b/packages/agent-runtime/src/types.ts index 8f56d26ebd..79ce25a681 100644 --- a/packages/agent-runtime/src/types.ts +++ b/packages/agent-runtime/src/types.ts @@ -28,7 +28,7 @@ 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; } diff --git a/packages/bb-app/src/launcher.ts b/packages/bb-app/src/launcher.ts index 491cb54f91..9137648425 100644 --- a/packages/bb-app/src/launcher.ts +++ b/packages/bb-app/src/launcher.ts @@ -294,7 +294,6 @@ interface LauncherCliOptions { help: boolean; hostDaemonPort?: string; hostId?: string; - hostType?: string; joinCode?: string; json?: boolean; serverBindHost?: string; @@ -727,7 +726,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" }, @@ -748,7 +746,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"]); @@ -768,9 +765,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; } @@ -889,9 +883,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; } @@ -922,14 +913,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, }; @@ -1100,6 +1093,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; } @@ -1127,28 +1126,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; } @@ -2979,7 +2962,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; @@ -3025,7 +3008,7 @@ Usage: bb-app config refresh bb-app env set bb-app client ssh-target set [--host-id ] - bb-app host-daemon [--server-url ] [--host-daemon-port ] [--host-id ] [--host-type ] [--enroll-key ] [--auto-update] + bb-app host-daemon [--server-url ] [--host-daemon-port ] [--host-id ] [--enroll-key ] [--auto-update] bb-app host-daemon join --server-url [--host-daemon-port ] [--join-code --host-id ] [--auto-update] CLI: diff --git a/packages/bb-app/test/index.test.ts b/packages/bb-app/test/index.test.ts index 57dded4509..f3bc37b02e 100644 --- a/packages/bb-app/test/index.test.ts +++ b/packages/bb-app/test/index.test.ts @@ -746,8 +746,6 @@ describe("bb-app launcher", () => { "host_remote", "--host-daemon-port", "48887", - "--host-type", - "persistent", "--auto-update", ]), ).toEqual({ @@ -757,7 +755,6 @@ describe("bb-app launcher", () => { help: false, hostDaemonPort: "48887", hostId: "host_remote", - hostType: "persistent", joinCode: "bbde_supplied", json: false, serverUrl: "https://bb.example.test", @@ -2248,3 +2245,33 @@ describe("bb-app launcher", () => { expect(invalidSurfaceServerEnv.BB_APP_SURFACE).toBe("web"); }); }); + +it("preserves machine identity and access headers through real config set and unset", async () => { + const dataDir = mkdtempSync(join(tmpdir(), "bb-app-config-machine-")); + const identity = { + serverUrl: "https://machine.example", + serverHeaders: { "x-bb-connect-machine": "private-machine-access" }, + machineCredential: "legacy-private", + connectMachineId: "cloud-device", + }; + try { + writeFileSync(join(dataDir, "config.json"), JSON.stringify(identity)); + await runBbApp([ + "--data-dir", + dataDir, + "config", + "set", + "BB_APP_URL", + "https://other.example", + ]); + expect( + JSON.parse(readFileSync(join(dataDir, "config.json"), "utf8")), + ).toMatchObject(identity); + await runBbApp(["--data-dir", dataDir, "config", "unset", "BB_APP_URL"]); + expect( + JSON.parse(readFileSync(join(dataDir, "config.json"), "utf8")), + ).toEqual(identity); + } finally { + rmSync(dataDir, { recursive: true, force: true }); + } +}); diff --git a/packages/config/src/bb-app-managed-config.ts b/packages/config/src/bb-app-managed-config.ts index 8b7e62da36..c02688a2fb 100644 --- a/packages/config/src/bb-app-managed-config.ts +++ b/packages/config/src/bb-app-managed-config.ts @@ -154,6 +154,7 @@ export const bbAppManagedConfigSchema = z customAcpAgents: customAcpAgentsSchema.optional(), customModels: z.array(customProviderModelSchema).optional(), sharedSkillRoots: providerNativeSkillRootsSchema.optional(), + serverHeaders: z.record(z.string(), z.string()).optional(), machineCredential: z.string().min(1).optional(), connectMachineId: z.string().min(1).optional(), serverUrl: z.string().min(1).optional(), @@ -166,6 +167,7 @@ const bbAppManagedConfigBoundarySchema = z customAcpAgents: z.array(z.unknown()).optional(), customModels: z.array(z.unknown()).optional(), sharedSkillRoots: providerNativeSkillRootsSchema.optional(), + serverHeaders: z.record(z.string(), z.string()).optional(), machineCredential: z.string().min(1).optional(), connectMachineId: z.string().min(1).optional(), serverUrl: z.string().min(1).optional(), @@ -278,6 +280,9 @@ export function parseBbAppManagedConfig( if (parsed.serverUrl !== undefined) { config.serverUrl = parsed.serverUrl; } + if (parsed.serverHeaders !== undefined) { + config.serverHeaders = parsed.serverHeaders; + } if (parsed.machineCredential !== undefined) { config.machineCredential = parsed.machineCredential; } diff --git a/packages/config/src/env-vars.ts b/packages/config/src/env-vars.ts index 5c7385757c..2fd1c298ba 100644 --- a/packages/config/src/env-vars.ts +++ b/packages/config/src/env-vars.ts @@ -1,5 +1,6 @@ +import { z } from "zod"; import { delimiter } from "node:path"; -import { defaultFeatureFlags, hostTypeSchema, type HostType } from "@bb/domain"; +import { defaultFeatureFlags } from "@bb/domain"; import { DEFAULTS } from "./defaults.js"; import { defineEnvVar, type EnvVarParseArgs } from "./env.js"; import { @@ -139,20 +140,6 @@ function parseTranscriptionModelValue(args: EnvVarParseArgs): string { return validateTranscriptionModel(args.value); } -function parseHostTypeValue(args: EnvVarParseArgs): HostType | undefined { - const trimmedValue = args.value.trim(); - if (trimmedValue.length === 0) { - return undefined; - } - - const parsedHostType = hostTypeSchema.safeParse(trimmedValue); - if (!parsedHostType.success) { - throw new Error(`Invalid ${args.name} "${trimmedValue}"`); - } - - return parsedHostType.data; -} - export const BB_LOG_LEVEL_ENV = defineEnvVar({ description: "Log level: trace, debug, info, warn, error, fatal", name: "BB_LOG_LEVEL", @@ -313,6 +300,20 @@ export const BB_BRIDGE_DIR_ENV = defineEnvVar({ parse: parseOptionalTrimmedStringEnvValue, }); +export const BB_SERVER_HEADERS_ENV = defineEnvVar>({ + description: "Private JSON headers attached to machine server requests", + name: "BB_SERVER_HEADERS", + parse: ({ value }) => { + try { + return z.record(z.string(), z.string()).parse(JSON.parse(value)); + } catch { + throw new Error( + "BB_SERVER_HEADERS must be a JSON object with string values", + ); + } + }, +}); + export const BB_CONNECT_MACHINE_CREDENTIAL_ENV = defineEnvVar< string | undefined >({ @@ -356,12 +357,6 @@ export const BB_HOST_NAME_ENV = defineEnvVar({ parse: parseOptionalTrimmedStringEnvValue, }); -export const BB_HOST_TYPE_ENV = defineEnvVar({ - description: "Host type override for daemon bootstrap", - name: "BB_HOST_TYPE", - parse: parseHostTypeValue, -}); - export const DEFAULT_BB_APP_VERSION = DEFAULTS.appVersion; export const DEFAULT_BB_APP_SURFACE = DEFAULT_APP_SURFACE; export const DEFAULT_BB_APP_URL = ""; diff --git a/packages/config/src/host-daemon-entrypoint.ts b/packages/config/src/host-daemon-entrypoint.ts index cf28202d72..322e949a75 100644 --- a/packages/config/src/host-daemon-entrypoint.ts +++ b/packages/config/src/host-daemon-entrypoint.ts @@ -1,32 +1,28 @@ -import type { HostType } from "@bb/domain"; import { readOptionalEnvVar, resolveEnvLoader, type EnvLoaderArgs, } from "./env.js"; import { + BB_SERVER_HEADERS_ENV, BB_BRIDGE_DIR_ENV, BB_CLI_DIR_ENV, BB_CONNECT_MACHINE_CREDENTIAL_ENV, - BB_CONNECT_MACHINE_ID_ENV, BB_HOST_ENROLL_KEY_ENV, BB_HOST_DAEMON_AUTO_UPDATE_ENV, BB_HOST_ID_ENV, BB_HOST_NAME_ENV, - BB_HOST_TYPE_ENV, } from "./env-vars.js"; import { assignIfDefined } from "./objects.js"; export interface HostDaemonEntrypointConfig { BB_BRIDGE_DIR?: string; BB_CLI_DIR?: string; - BB_CONNECT_MACHINE_CREDENTIAL?: string; - BB_CONNECT_MACHINE_ID?: string; + BB_SERVER_HEADERS?: Record; BB_HOST_ENROLL_KEY?: string; BB_HOST_DAEMON_AUTO_UPDATE?: boolean; BB_HOST_ID?: string; BB_HOST_NAME?: string; - BB_HOST_TYPE?: HostType; } type LoadHostDaemonEntrypointConfigArgs = EnvLoaderArgs; @@ -61,11 +57,15 @@ export function loadHostDaemonEntrypointConfig( definition: BB_CONNECT_MACHINE_CREDENTIAL_ENV, env: loader.env, }); - const connectMachineId = readOptionalEnvVar({ - context: loader.context, - definition: BB_CONNECT_MACHINE_ID_ENV, - env: loader.env, - }); + const serverHeaders = + readOptionalEnvVar({ + context: loader.context, + definition: BB_SERVER_HEADERS_ENV, + env: loader.env, + }) ?? + (machineCredential === undefined + ? undefined + : { "x-bb-connect-machine": machineCredential }); const hostId = readOptionalEnvVar({ context: loader.context, definition: BB_HOST_ID_ENV, @@ -76,31 +76,21 @@ export function loadHostDaemonEntrypointConfig( definition: BB_HOST_NAME_ENV, env: loader.env, }); - const hostType = readOptionalEnvVar({ - context: loader.context, - definition: BB_HOST_TYPE_ENV, - env: loader.env, - }); assignIfDefined({ key: "BB_BRIDGE_DIR", target: config, value: bridgeDir, }); - assignIfDefined({ - key: "BB_CONNECT_MACHINE_ID", - target: config, - value: connectMachineId, - }); assignIfDefined({ key: "BB_CLI_DIR", target: config, value: cliDir, }); assignIfDefined({ - key: "BB_CONNECT_MACHINE_CREDENTIAL", + key: "BB_SERVER_HEADERS", target: config, - value: machineCredential, + value: serverHeaders, }); assignIfDefined({ key: "BB_HOST_DAEMON_AUTO_UPDATE", @@ -122,11 +112,5 @@ export function loadHostDaemonEntrypointConfig( target: config, value: hostName, }); - assignIfDefined({ - key: "BB_HOST_TYPE", - target: config, - value: hostType, - }); - return config; } diff --git a/packages/config/test/config.test.ts b/packages/config/test/config.test.ts index 7cd1c18291..5b17842d0b 100644 --- a/packages/config/test/config.test.ts +++ b/packages/config/test/config.test.ts @@ -753,7 +753,6 @@ describe("consumer-specific config", () => { BB_HOST_DAEMON_AUTO_UPDATE: "true", BB_HOST_ID: " host-123 ", BB_HOST_NAME: " host-123 ", - BB_HOST_TYPE: "persistent", }, }); @@ -764,7 +763,6 @@ describe("consumer-specific config", () => { BB_HOST_DAEMON_AUTO_UPDATE: true, BB_HOST_ID: "host-123", BB_HOST_NAME: "host-123", - BB_HOST_TYPE: "persistent", }); }); @@ -775,22 +773,11 @@ describe("consumer-specific config", () => { BB_CLI_DIR: " ", BB_HOST_ENROLL_KEY: " ", BB_HOST_NAME: "", - BB_HOST_TYPE: "", }, }); expect(hostDaemonEntrypointConfig).toEqual({}); }); - - it("rejects invalid host-daemon entrypoint host types", () => { - expect(() => - loadHostDaemonEntrypointConfig({ - env: { - BB_HOST_TYPE: "ephemeral", - }, - }), - ).toThrow('Invalid BB_HOST_TYPE "ephemeral"'); - }); }); describe("provider model config", () => { diff --git a/packages/db/drizzle/0114_machine_providers.sql b/packages/db/drizzle/0114_machine_providers.sql new file mode 100644 index 0000000000..5824ae7aba --- /dev/null +++ b/packages/db/drizzle/0114_machine_providers.sql @@ -0,0 +1,104 @@ +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 +CREATE TABLE `environment_setup_outcomes` ( + `host_id` text NOT NULL, + `path` text NOT NULL, + `operation_id` text NOT NULL, + `state` text NOT NULL, + `input_hash` text, + `updated_at` integer NOT NULL, + PRIMARY KEY(`host_id`, `path`), + FOREIGN KEY (`host_id`) REFERENCES `hosts`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE TABLE `machine_enrollments` ( + `id` text PRIMARY KEY NOT NULL, + `owner` text NOT NULL, + `key` text NOT NULL, + `host_id` text NOT NULL, + `state` text NOT NULL, + `encrypted_bootstrap` text, + `expires_at` integer, + `created_at` integer NOT NULL, + `updated_at` integer NOT NULL +); +--> statement-breakpoint +CREATE UNIQUE INDEX `machine_enrollments_owner_key_idx` ON `machine_enrollments` (`owner`,`key`);--> statement-breakpoint +CREATE UNIQUE INDEX `machine_enrollments_host_id_idx` ON `machine_enrollments` (`host_id`);--> statement-breakpoint +CREATE TABLE `machine_launches` ( + `key` text PRIMARY KEY NOT NULL, + `provider_id` text NOT NULL, + `project_id` text, + `inputs` text, + `attempt` integer NOT NULL, + `phase` text NOT NULL, + `started_at` integer NOT NULL, + `failed_at` integer, + `failure` text, + `message` text, + `transient_failures` integer NOT NULL, + `host_id` text, + `resource` text, + `step_text` text NOT NULL, + `pending_log` text NOT NULL, + `cleanup_retry_at` integer, + `cleanup_resource_removed` integer DEFAULT false NOT NULL, + `cancel_pending` integer NOT NULL +); +--> statement-breakpoint +CREATE INDEX `machine_launches_phase_idx` ON `machine_launches` (`phase`);--> statement-breakpoint +CREATE INDEX `machine_launches_host_id_idx` ON `machine_launches` (`host_id`);--> statement-breakpoint +CREATE TABLE `machine_lifecycles` ( + `host_id` text PRIMARY KEY NOT NULL, + `observed_state` text NOT NULL, + `observed_at` integer NOT NULL, + `expires_at` integer, + `maintenance_at` integer, + `last_snapshot_at` integer, + `restore_operation_id` text, + `restore_checkouts` text, + `recovery_state` text NOT NULL, + `message` text, + `lease_id` text, + `lease_until` integer, + `retry_at` integer, + `idle_suspend_ms` integer, + `retire_after_ms` integer, + `deadline_lead_ms` integer, + `unused_since` integer, + `retention_at` integer, + `keep` integer DEFAULT false NOT NULL, + FOREIGN KEY (`host_id`) REFERENCES `hosts`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +ALTER TABLE `hosts` ADD `machine_provider_id` text;--> statement-breakpoint +ALTER TABLE `hosts` ADD `machine_operation_id` text;--> statement-breakpoint +ALTER TABLE `hosts` ADD `server_access_provider_id` text;--> 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 `machine_provider_selection` 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 `idle_since` integer;--> statement-breakpoint +ALTER TABLE `hosts` ADD `removal_started_at` integer;--> statement-breakpoint +ALTER TABLE `hosts` ADD `retire_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 +ALTER TABLE `hosts` ADD `teardown_message` text;--> statement-breakpoint +ALTER TABLE `hosts` DROP COLUMN `type`;--> 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/0114_snapshot.json b/packages/db/drizzle/meta/0114_snapshot.json new file mode 100644 index 0000000000..c1e222d9d6 --- /dev/null +++ b/packages/db/drizzle/meta/0114_snapshot.json @@ -0,0 +1,4751 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "42e8eea4-0c4d-4118-98c6-375d25649aa7", + "prevId": "461ff072-1617-4ce2-b12a-731f82ec3629", + "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": {} + }, + "environment_launches": { + "name": "environment_launches", + "columns": { + "thread_id": { + "name": "thread_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_plugin_id": { + "name": "provider_plugin_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "path_rejected": { + "name": "path_rejected", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "failed_at": { + "name": "failed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure": { + "name": "failure", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transient_failures": { + "name": "transient_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path_key": { + "name": "path_key", + "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 + }, + "claim_path": { + "name": "claim_path", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "owns_path": { + "name": "owns_path", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "merge_base_branch": { + "name": "merge_base_branch", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "step_text": { + "name": "step_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "replaced_environment_id": { + "name": "replaced_environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "environment_id": { + "name": "environment_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "selection": { + "name": "selection", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "request": { + "name": "request", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cancel_pending": { + "name": "cancel_pending", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "environment_launches_phase_idx": { + "name": "environment_launches_phase_idx", + "columns": [ + "phase" + ], + "isUnique": false + }, + "environment_launches_active_claim_idx": { + "name": "environment_launches_active_claim_idx", + "columns": [ + "host_id", + "claim_path" + ], + "isUnique": false, + "where": "\"environment_launches\".\"environment_id\" is null and \"environment_launches\".\"claim_path\" is not null" + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "environment_setup_outcomes": { + "name": "environment_setup_outcomes", + "columns": { + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "operation_id": { + "name": "operation_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "input_hash": { + "name": "input_hash", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": {}, + "foreignKeys": { + "environment_setup_outcomes_host_id_hosts_id_fk": { + "name": "environment_setup_outcomes_host_id_hosts_id_fk", + "tableFrom": "environment_setup_outcomes", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "environment_setup_outcomes_host_id_path_pk": { + "columns": [ + "host_id", + "path" + ], + "name": "environment_setup_outcomes_host_id_path_pk" + } + }, + "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 + }, + "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_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 + }, + "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 + }, + "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 + }, + "machine_provider_selection": { + "name": "machine_provider_selection", + "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 + }, + "idle_since": { + "name": "idle_since", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "removal_started_at": { + "name": "removal_started_at", + "type": "integer", + "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 + }, + "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 + } + }, + "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": {} + }, + "machine_enrollments": { + "name": "machine_enrollments", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "owner": { + "name": "owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "encrypted_bootstrap": { + "name": "encrypted_bootstrap", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "expires_at": { + "name": "expires_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": { + "machine_enrollments_owner_key_idx": { + "name": "machine_enrollments_owner_key_idx", + "columns": [ + "owner", + "key" + ], + "isUnique": true + }, + "machine_enrollments_host_id_idx": { + "name": "machine_enrollments_host_id_idx", + "columns": [ + "host_id" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machine_launches": { + "name": "machine_launches", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "inputs": { + "name": "inputs", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "phase": { + "name": "phase", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "started_at": { + "name": "started_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "failed_at": { + "name": "failed_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "failure": { + "name": "failure", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "transient_failures": { + "name": "transient_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "step_text": { + "name": "step_text", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pending_log": { + "name": "pending_log", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "cleanup_retry_at": { + "name": "cleanup_retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "cleanup_resource_removed": { + "name": "cleanup_resource_removed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "cancel_pending": { + "name": "cancel_pending", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "machine_launches_phase_idx": { + "name": "machine_launches_phase_idx", + "columns": [ + "phase" + ], + "isUnique": false + }, + "machine_launches_host_id_idx": { + "name": "machine_launches_host_id_idx", + "columns": [ + "host_id" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "machine_lifecycles": { + "name": "machine_lifecycles", + "columns": { + "host_id": { + "name": "host_id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "observed_state": { + "name": "observed_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "observed_at": { + "name": "observed_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "maintenance_at": { + "name": "maintenance_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_snapshot_at": { + "name": "last_snapshot_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "restore_operation_id": { + "name": "restore_operation_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "restore_checkouts": { + "name": "restore_checkouts", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "recovery_state": { + "name": "recovery_state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_id": { + "name": "lease_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "lease_until": { + "name": "lease_until", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retry_at": { + "name": "retry_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "idle_suspend_ms": { + "name": "idle_suspend_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retire_after_ms": { + "name": "retire_after_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "deadline_lead_ms": { + "name": "deadline_lead_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "unused_since": { + "name": "unused_since", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "retention_at": { + "name": "retention_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "keep": { + "name": "keep", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "machine_lifecycles_host_id_hosts_id_fk": { + "name": "machine_lifecycles_host_id_hosts_id_fk", + "tableFrom": "machine_lifecycles", + "tableTo": "hosts", + "columnsFrom": [ + "host_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "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": {} + }, + "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'" + }, + "pending_start_context": { + "name": "pending_start_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": {} + } + }, + "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 c45139b32d..60cdf0b910 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -799,6 +799,13 @@ "when": 1788893744236, "tag": "0113_environment_providers", "breakpoints": true + }, + { + "idx": 114, + "version": "6", + "when": 1788898043909, + "tag": "0114_machine_providers", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/data/hosts.ts b/packages/db/src/data/hosts.ts index e01064864d..ee70bb7f8c 100644 --- a/packages/db/src/data/hosts.ts +++ b/packages/db/src/data/hosts.ts @@ -1,8 +1,13 @@ -import { and, eq, inArray, isNull } from "drizzle-orm"; -import type { HostChangeKind, HostType, PermissionMode } from "@bb/domain"; +import { and, eq, inArray, isNull, isNotNull, notExists, ne, or } from "drizzle-orm"; +import type { + HostChangeKind, + JsonValue, + MachineProviderSelection, + PermissionMode, +} from "@bb/domain"; import type { DbConnection, DbTransaction } from "../connection.js"; import type { DbNotifier } from "../notifier.js"; -import { hosts } from "../schema.js"; +import { hosts, machineEnrollments, machineLaunches } from "../schema.js"; import { createHostId } from "../ids.js"; type HostWriteConnection = DbConnection | DbTransaction; @@ -11,15 +16,25 @@ export interface UpsertHostInput { connectMachineId?: string | null; id?: string; name: string; - type: HostType; destroyedAt?: number | null; } export interface UpdateHostInput { + machineOperationId?: string | null; destroyedAt?: number | null; lastRejectedProtocolVersion?: number | null; maxPermissionMode?: PermissionMode; name?: string; + machineProviderId?: string | null; + machineProviderSelection?: MachineProviderSelection | null; + phase?: "active" | "suspending" | "suspended" | "retiring" | "destroyed"; + resource?: JsonValue | null; + removalStartedAt?: number | null; + retireAt?: number | null; + suspendedAt?: number | null; + teardownAttempt?: number; + teardownMessage?: string | null; + teardownStatus?: "running" | "failed" | "removed" | null; } function notifyHostMutation( @@ -67,7 +82,6 @@ export function upsertHost( const updated = db .update(hosts) .set({ - type: input.type, connectMachineId: input.connectMachineId !== undefined ? input.connectMachineId @@ -91,8 +105,16 @@ export function upsertHost( .values({ id, name: input.name, - type: input.type, connectMachineId: input.connectMachineId ?? null, + machineProviderId: null, + resource: null, + machineProviderSelection: null, + phase: "active", + suspendedAt: null, + retireAt: null, + teardownAttempt: 0, + teardownStatus: null, + teardownMessage: null, destroyedAt: input.destroyedAt ?? null, lastSeenAt: null, lastRejectedProtocolVersion: null, @@ -136,11 +158,16 @@ export function listHosts(db: DbConnection) { } export function listPublicHosts(db: DbConnection) { - return db - .select() - .from(hosts) - .where(and(eq(hosts.type, "persistent"), isNull(hosts.destroyedAt))) - .all(); + return db.select().from(hosts).where(and( + isNull(hosts.destroyedAt), + or( + and(isNotNull(hosts.serverAccessProviderId), isNull(hosts.serverAccessGrantId), isNotNull(hosts.teardownMessage)), + and( + or(isNotNull(hosts.lastSeenAt), notExists(db.select({ id: machineEnrollments.id }).from(machineEnrollments).where(eq(machineEnrollments.hostId, hosts.id)))), + notExists(db.select({ key: machineLaunches.key }).from(machineLaunches).where(and(eq(machineLaunches.hostId, hosts.id), ne(machineLaunches.phase, "ready")))), + ), + ), + )).all(); } export function listNonDestroyedHostsByIds( @@ -158,6 +185,11 @@ export function listNonDestroyedHostsByIds( .all(); } +export function settleMachineEnrollments(db: DbConnection, hostId: string): void { + db.update(machineEnrollments).set({ state: "cancelled", encryptedBootstrap: null, expiresAt: null, updatedAt: Date.now() }) + .where(and(eq(machineEnrollments.hostId, hostId), or(ne(machineEnrollments.state, "cancelled"), isNotNull(machineEnrollments.encryptedBootstrap), isNotNull(machineEnrollments.expiresAt)))).run(); +} + export function updateHost( db: DbConnection, notifier: DbNotifier, @@ -170,6 +202,7 @@ export function updateHost( } const now = Date.now(); + if (input.destroyedAt != null) settleMachineEnrollments(db, hostId); db.update(hosts) .set({ ...(input.destroyedAt !== undefined @@ -182,6 +215,34 @@ export function updateHost( ...(input.lastRejectedProtocolVersion !== undefined ? { lastRejectedProtocolVersion: input.lastRejectedProtocolVersion } : {}), + ...(input.machineProviderId !== undefined + ? { machineProviderId: input.machineProviderId } + : {}), + ...(input.machineProviderSelection !== undefined + ? { machineProviderSelection: input.machineProviderSelection } + : {}), + ...(input.machineOperationId !== undefined ? { machineOperationId: input.machineOperationId } : {}), + ...(input.phase !== undefined ? { phase: input.phase } : {}), + ...(input.phase === "active" && existing.phase !== "active" + ? { idleSince: now } + : {}), + ...(input.resource !== undefined ? { resource: input.resource } : {}), + ...(input.removalStartedAt !== undefined + ? { removalStartedAt: input.removalStartedAt } + : {}), + ...(input.retireAt !== undefined ? { retireAt: input.retireAt } : {}), + ...(input.suspendedAt !== undefined + ? { suspendedAt: input.suspendedAt } + : {}), + ...(input.teardownAttempt !== undefined + ? { teardownAttempt: input.teardownAttempt } + : {}), + ...(input.teardownMessage !== undefined + ? { teardownMessage: input.teardownMessage } + : {}), + ...(input.teardownStatus !== undefined + ? { teardownStatus: input.teardownStatus } + : {}), updatedAt: now, }) .where(eq(hosts.id, hostId)) @@ -202,6 +263,7 @@ export function deleteHost( return false; } + settleMachineEnrollments(db, hostId); db.delete(hosts).where(eq(hosts.id, hostId)).run(); notifier.notifyHost(existing.id, ["host-disconnected"]); return true; diff --git a/packages/db/src/data/index.ts b/packages/db/src/data/index.ts index d830a4bd42..c5bdd63905 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, @@ -238,6 +239,7 @@ export { listHosts, listNonDestroyedHostsByIds, listPublicHosts, + settleMachineEnrollments, updateHost, deleteHost, } from "./hosts.js"; @@ -445,3 +447,5 @@ export { shouldRunIncrementalVacuum, } from "./maintenance.js"; export * from "./environment-launches.js"; +export * from "./machine-launches.js"; +export * from "./machines.js"; diff --git a/packages/db/src/data/machine-launches.ts b/packages/db/src/data/machine-launches.ts new file mode 100644 index 0000000000..62d093d1cd --- /dev/null +++ b/packages/db/src/data/machine-launches.ts @@ -0,0 +1,54 @@ +import { and, eq } from "drizzle-orm"; +import type { DbConnection } from "../connection.js"; +import { machineLaunches } from "../schema.js"; + +export type MachineLaunchRow = typeof machineLaunches.$inferSelect; + +export function getMachineLaunch(db: DbConnection, key: string) { + return ( + db.select().from(machineLaunches).where(eq(machineLaunches.key, key)).get() ?? + null + ); +} + +export function upsertMachineLaunch( + db: DbConnection, + row: typeof machineLaunches.$inferInsert, +): void { + db.insert(machineLaunches) + .values(row) + .onConflictDoUpdate({ target: machineLaunches.key, set: row }) + .run(); +} + +export function updateMachineLaunchAttempt( + db: DbConnection, + row: Partial & { + key: string; + attempt: number; + }, +): boolean { + return ( + db + .update(machineLaunches) + .set(row) + .where( + and( + eq(machineLaunches.key, row.key), + eq(machineLaunches.attempt, row.attempt), + ), + ) + .run().changes > 0 + ); +} + +export function listMachineLaunchesByPhase( + db: DbConnection, + phase: MachineLaunchRow["phase"], +): MachineLaunchRow[] { + return db + .select() + .from(machineLaunches) + .where(eq(machineLaunches.phase, phase)) + .all(); +} diff --git a/packages/db/src/data/machines.ts b/packages/db/src/data/machines.ts new file mode 100644 index 0000000000..342239bf07 --- /dev/null +++ b/packages/db/src/data/machines.ts @@ -0,0 +1,101 @@ +import { and, eq, inArray, isNull, or } from "drizzle-orm"; +import type { DbConnection, DbTransaction } from "../connection.js"; +import { environments, hosts, terminalSessions, 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 machineIdleSince( + db: Connection, + hostId: string, +): number | null { + const rows = db + .select({ status: threads.status, updatedAt: threads.updatedAt }) + .from(threads) + .innerJoin(environments, eq(threads.environmentId, environments.id)) + .where( + and( + eq(environments.hostId, hostId), + liveThreadCondition, + ), + ) + .all(); + const host = db + .select({ idleSince: hosts.idleSince }) + .from(hosts) + .where(eq(hosts.id, hostId)) + .get(); + if (host === undefined) return null; + if ( + rows.some((row) => row.status !== "idle") || + machineHasOpenTerminal(db, hostId) + ) { + db.update(hosts) + .set({ idleSince: Date.now() }) + .where(eq(hosts.id, hostId)) + .run(); + return null; + } + const latestThreadActivity = rows.length === 0 + ? null + : Math.max(...rows.map((row) => row.updatedAt)); + const baseline = host.idleSince ?? latestThreadActivity ?? Date.now(); + if (host.idleSince === null) { + db.update(hosts) + .set({ idleSince: baseline }) + .where(eq(hosts.id, hostId)) + .run(); + } + return Math.max(baseline, latestThreadActivity ?? baseline); +} + +export function machineHasOpenTerminal( + db: Connection, + hostId: string, +): boolean { + return ( + db + .select({ id: terminalSessions.id }) + .from(terminalSessions) + .where( + and( + eq(terminalSessions.hostId, hostId), + inArray(terminalSessions.status, [ + "starting", + "running", + "disconnected", + ]), + ), + ) + .limit(1) + .get() !== undefined + ); +} diff --git a/packages/db/src/data/project-sources.ts b/packages/db/src/data/project-sources.ts index 74f394a058..416f2c961f 100644 --- a/packages/db/src/data/project-sources.ts +++ b/packages/db/src/data/project-sources.ts @@ -13,6 +13,7 @@ export interface CreateLocalPathProjectSourceInput { hostId: string; path: string; isDefault?: boolean; + ownsPath?: boolean; } export type CreateProjectSourceInput = CreateLocalPathProjectSourceInput; @@ -65,6 +66,7 @@ export function createProjectSource( hostId: input.hostId, path: input.path, isDefault: shouldBeDefault, + ownsPath: input.ownsPath ?? false, createdAt: now, updatedAt: now, }) @@ -286,3 +288,10 @@ export function deleteProjectSource( notifier.notifyProject(deleted, ["project-sources-changed"]); return true; } + + +export function projectSourceOwnsPath(db: DbConnection, projectId: string, hostId: string, path: string): boolean { + return db.select({ ownsPath: projectSources.ownsPath }).from(projectSources).where(and( + eq(projectSources.projectId, projectId), eq(projectSources.hostId, hostId), eq(projectSources.path, path), + )).get()?.ownsPath ?? false; +} diff --git a/packages/db/src/data/sessions.ts b/packages/db/src/data/sessions.ts index 46bb452504..7dcbcd5800 100644 --- a/packages/db/src/data/sessions.ts +++ b/packages/db/src/data/sessions.ts @@ -1,5 +1,4 @@ import { and, desc, eq, inArray, sql } from "drizzle-orm"; -import type { HostType } from "@bb/domain"; import type { DbConnection, DbTransaction } from "../connection.js"; import type { DbNotifier } from "../notifier.js"; import { hostDaemonSessions } from "../schema.js"; @@ -25,7 +24,6 @@ export interface OpenSessionInput { hostId: string; instanceId: string; hostName: string; - hostType: HostType; dataDir: string; protocolVersion: number; heartbeatIntervalMs: number; @@ -63,7 +61,6 @@ export function openSession( hostId: input.hostId, instanceId: input.instanceId, hostName: input.hostName, - hostType: input.hostType, dataDir: input.dataDir, protocolVersion: input.protocolVersion, heartbeatIntervalMs: input.heartbeatIntervalMs, diff --git a/packages/db/src/migrate.ts b/packages/db/src/migrate.ts index 19501a5ec3..f7bd33e5e2 100644 --- a/packages/db/src/migrate.ts +++ b/packages/db/src/migrate.ts @@ -1485,6 +1485,20 @@ export function migrate(db: DbConnection, options: MigrateOptions = {}): void { const migrationsFolder = resolveMigrationsFolder(); const sqlite = db.$client; + sqlite.exec( + "CREATE TEMP TABLE IF NOT EXISTS bb_migration_local_host (id TEXT PRIMARY KEY)", + ); + sqlite.exec("DELETE FROM bb_migration_local_host"); + if (sqlite.name !== ":memory:") { + const identityPath = join(dirname(sqlite.name), "host-id"); + if (existsSync(identityPath)) { + const hostId = readFileSync(identityPath, "utf8").trim(); + if (hostId) + sqlite + .prepare("INSERT INTO bb_migration_local_host (id) VALUES (?)") + .run(hostId); + } + } sqlite.pragma("foreign_keys = OFF"); try { assertNoDuplicatePendingInteractionProviderRequests(db); diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index b605cd0c51..fd0f402820 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -18,7 +18,7 @@ import type { JsonValue, EnvironmentStatus, FaviconColorPreference, - HostType, + MachineProviderSelection, PendingInteractionStatus, PermissionMode, PromptHistoryScope, @@ -93,8 +93,28 @@ export const hosts = sqliteTable( { id: text("id").primaryKey(), name: text("name").notNull(), - type: text("type").$type().notNull(), connectMachineId: text("connect_machine_id"), + machineProviderId: text("machine_provider_id"), + machineOperationId: text("machine_operation_id"), + serverAccessProviderId: text("server_access_provider_id"), + serverAccessGrantId: text("server_access_grant_id"), + resource: text("resource", { mode: "json" }).$type(), + machineProviderSelection: text("machine_provider_selection", { + mode: "json", + }).$type(), + phase: text("phase") + .$type<"active" | "suspending" | "suspended" | "retiring" | "destroyed">() + .notNull() + .default("active"), + suspendedAt: integer("suspended_at"), + idleSince: integer("idle_since"), + removalStartedAt: integer("removal_started_at"), + retireAt: integer("retire_at"), + teardownAttempt: integer("teardown_attempt").notNull().default(0), + teardownStatus: text("teardown_status").$type< + "running" | "failed" | "removed" + >(), + teardownMessage: text("teardown_message"), maxPermissionMode: text("max_permission_mode") .$type() .notNull() @@ -108,6 +128,27 @@ export const hosts = sqliteTable( (table) => [index("hosts_last_seen_idx").on(table.lastSeenAt)], ); +export const machineEnrollments = sqliteTable( + "machine_enrollments", + { + id: text("id").primaryKey(), + owner: text("owner").notNull(), + key: text("key").notNull(), + hostId: text("host_id").notNull(), + state: text("state") + .$type<"pending" | "enrolled" | "cancelled">() + .notNull(), + encryptedBootstrap: text("encrypted_bootstrap"), + expiresAt: integer("expires_at"), + createdAt: integer("created_at").notNull(), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [ + uniqueIndex("machine_enrollments_owner_key_idx").on(table.owner, table.key), + uniqueIndex("machine_enrollments_host_id_idx").on(table.hostId), + ], +); + export const projects = sqliteTable( "projects", { @@ -410,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), @@ -917,7 +961,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(), @@ -1090,3 +1133,103 @@ export const environmentLaunches = sqliteTable( ), ], ); + +export const machineLaunches = sqliteTable( + "machine_launches", + { + key: text("key").primaryKey(), + providerId: text("provider_id").notNull(), + projectId: text("project_id"), + inputs: text("inputs", { mode: "json" }).$type(), + attempt: integer("attempt").notNull(), + phase: text("phase") + .$type<"creating" | "ready" | "failed" | "cancelled">() + .notNull(), + startedAt: integer("started_at").notNull(), + failedAt: integer("failed_at"), + failure: text("failure").$type<"terminal" | "transient">(), + message: text("message"), + transientFailures: integer("transient_failures").notNull(), + hostId: text("host_id"), + resource: text("resource", { mode: "json" }).$type(), + stepText: text("step_text").notNull(), + pendingLog: text("pending_log").notNull(), + cleanupRetryAt: integer("cleanup_retry_at"), + cleanupResourceRemoved: integer("cleanup_resource_removed", { + mode: "boolean", + }) + .notNull() + .default(false), + cancelPending: integer("cancel_pending", { mode: "boolean" }).notNull(), + }, + (table) => [ + index("machine_launches_phase_idx").on(table.phase), + index("machine_launches_host_id_idx").on(table.hostId), + ], +); + +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"), + }, +); + +export const environmentSetupOutcomes = sqliteTable( + "environment_setup_outcomes", + { + hostId: text("host_id") + .notNull() + .references(() => hosts.id, { onDelete: "cascade" }), + path: text("path").notNull(), + operationId: text("operation_id").notNull(), + state: text("state", { enum: ["running", "passed", "failed"] }).notNull(), + inputHash: text("input_hash"), + updatedAt: integer("updated_at").notNull(), + }, + (table) => [primaryKey({ columns: [table.hostId, table.path] })], +); + +export const machineLifecycles = sqliteTable("machine_lifecycles", { + hostId: text("host_id") + .primaryKey() + .references(() => hosts.id, { onDelete: "cascade" }), + observedState: text("observed_state", { + enum: ["running", "suspended", "missing", "unknown"], + }).notNull(), + observedAt: integer("observed_at").notNull(), + expiresAt: integer("expires_at"), + maintenanceAt: integer("maintenance_at"), + lastSnapshotAt: integer("last_snapshot_at"), + restoreOperationId: text("restore_operation_id"), + restoreCheckouts: text("restore_checkouts", { mode: "json" }).$type< + Array<{ id: string; path: string }> + >(), + recoveryState: text("recovery_state", { + enum: [ + "healthy", + "draining", + "saving", + "saved", + "recoverable", + "lost-since-last-snapshot", + ], + }).notNull(), + message: text("message"), + leaseId: text("lease_id"), + leaseUntil: integer("lease_until"), + retryAt: integer("retry_at"), + idleSuspendMs: integer("idle_suspend_ms"), + retireAfterMs: integer("retire_after_ms"), + deadlineLeadMs: integer("deadline_lead_ms"), + unusedSince: integer("unused_since"), + retentionAt: integer("retention_at"), + keep: integer("keep", { mode: "boolean" }).notNull().default(false), +}); diff --git a/packages/db/test/connection.test.ts b/packages/db/test/connection.test.ts index 898ab2d250..2796d9c05f 100644 --- a/packages/db/test/connection.test.ts +++ b/packages/db/test/connection.test.ts @@ -88,7 +88,6 @@ describe("createConnection", () => { createdAt: 1, id: "host-drizzle", name: "Drizzle Host", - type: "persistent", updatedAt: 1, }) .run(); 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..2143cad96e 100644 --- a/packages/db/test/data/environments.test.ts +++ b/packages/db/test/data/environments.test.ts @@ -20,7 +20,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", @@ -246,7 +245,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", diff --git a/packages/db/test/data/events.test.ts b/packages/db/test/data/events.test.ts index 8bb48069a5..8309af79a1 100644 --- a/packages/db/test/data/events.test.ts +++ b/packages/db/test/data/events.test.ts @@ -70,7 +70,6 @@ function setup() { const db = createMigratedConnection(); const host = upsertHost(db, noopNotifier, { name: "test-host", - type: "persistent", }); const { project } = createProject(db, noopNotifier, { name: "test-project", @@ -4652,7 +4651,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, { @@ -4756,7 +4755,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..a209f803b9 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,17 +144,20 @@ 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, destroyedHost.id, { destroyedAt: 123 }); expect(listPublicHosts(db).map((host) => host.id)).toEqual([ visibleHost.id, + ephemeralHost.id, ]); }); @@ -174,12 +166,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 +187,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 +212,6 @@ describe("hosts", () => { }; const host = upsertHost(db, notifier, { name: "Persistent Host", - type: "persistent", }); notifyHost.mockClear(); @@ -249,7 +237,6 @@ describe("hosts", () => { }; const host = upsertHost(db, notifier, { name: "Persistent Host", - type: "persistent", }); notifyHost.mockClear(); @@ -272,7 +259,6 @@ describe("hosts", () => { }; const host = upsertHost(db, notifier, { name: "Transient Host", - type: "persistent", }); notifyHost.mockClear(); 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/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 c93ffff15d..a8c49387c7 100644 --- a/packages/db/test/data/sweeps.test.ts +++ b/packages/db/test/data/sweeps.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", @@ -422,7 +421,6 @@ describe("pruneClosedSessions", () => { hostId: args.hostId, instanceId: args.instanceId, hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -461,7 +459,6 @@ describe("pruneClosedSessions", () => { hostId: host.id, instanceId: "inst-active", hostName: "test-host", - hostType: "persistent", dataDir: "/tmp/test-host-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -500,7 +497,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..13e4b68211 --- /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 with one migration 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, -1)) { + 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..c905ab5549 --- /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/0114_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 69d4e59bc2..85b252af88 100644 --- a/packages/db/test/migrate.test.ts +++ b/packages/db/test/migrate.test.ts @@ -785,8 +785,67 @@ function rewindEnvironmentRowFactsMigration(db: DbConnection): void { function rewindEnvironmentProvidersMigration(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 TABLE IF EXISTS machine_workspace_setups"); + db.$client.exec("DROP TABLE IF EXISTS environment_setup_outcomes"); + db.$client.exec("DROP TABLE IF EXISTS machine_lifecycles"); db.$client.exec("DROP TABLE IF EXISTS environment_launches"); + db.$client.exec("DROP TABLE IF EXISTS machine_launches"); + db.$client.exec("DROP TABLE IF EXISTS machine_enrollments"); 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(); + } + for (const column of [ + "machine_provider_id", + "machine_operation_id", + "server_access_provider_id", + "server_access_grant_id", + "resource", + "machine_provider_selection", + "phase", + "suspended_at", + "idle_since", + "removal_started_at", + "retire_at", + "teardown_attempt", + "teardown_status", + "teardown_message", + ]) { + 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(); + } const lifecycleColumns = [ "environment_provider_plugin_id", "canonical_path", @@ -1736,6 +1795,8 @@ describe("migrate", () => { showDiagnosticEvents: true, providerOrder: [], defaultProviderId: null, + machineServerUrl: null, + defaultMachineAccess: null, streamerMode: false, managedBranchPrefix: "bb/", }); @@ -2054,7 +2115,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", @@ -2134,7 +2194,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", @@ -3546,7 +3605,6 @@ describe("migrate", () => { INSERT INTO hosts ( id, name, - type, command_cursor, created_at, updated_at @@ -3554,7 +3612,6 @@ describe("migrate", () => { VALUES ( 'host_deferred_cleanup', 'Deferred cleanup host', - 'persistent', 0, 1000, 1000 @@ -5284,7 +5341,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", @@ -5353,7 +5409,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", diff --git a/packages/db/test/query-plans.test.ts b/packages/db/test/query-plans.test.ts index 0c29ec9405..43ad6b3a61 100644 --- a/packages/db/test/query-plans.test.ts +++ b/packages/db/test/query-plans.test.ts @@ -127,7 +127,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", @@ -655,7 +654,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..a87792af79 100644 --- a/packages/db/test/schema.test.ts +++ b/packages/db/test/schema.test.ts @@ -158,7 +158,6 @@ describe("db rebuild schema", () => { .values({ id: hostId, name: "Local host", - type: "persistent", lastSeenAt: now, createdAt: now, updatedAt: now, @@ -225,7 +224,6 @@ describe("db rebuild schema", () => { hostId, instanceId: "instance-1", hostName: "Local host", - hostType: "persistent", dataDir: "/tmp/test-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -312,7 +310,6 @@ describe("db rebuild schema", () => { .values({ id: hostId, name: "Local host", - type: "persistent", lastSeenAt: now, createdAt: now, updatedAt: now, @@ -359,7 +356,6 @@ describe("db rebuild schema", () => { .values({ id: hostId, name: "Local host", - type: "persistent", lastSeenAt: now, createdAt: now, updatedAt: now, @@ -419,7 +415,6 @@ describe("db rebuild schema", () => { .values({ id: hostId, name: "Local host", - type: "persistent", lastSeenAt: now, createdAt: now, updatedAt: now, @@ -479,7 +474,6 @@ describe("db rebuild schema", () => { .values({ id: hostId, name: "Local host", - type: "persistent", lastSeenAt: now, createdAt: now, updatedAt: now, @@ -512,7 +506,6 @@ describe("db rebuild schema", () => { hostId, instanceId: "instance-1", hostName: "Local host", - hostType: "persistent", dataDir: "/tmp/test-data", protocolVersion: 1, heartbeatIntervalMs: 10_000, @@ -545,7 +538,6 @@ describe("db rebuild schema", () => { .values({ id: hostId, name: "Local host", - type: "persistent", lastSeenAt: now, createdAt: now, updatedAt: now, @@ -756,7 +748,6 @@ describe("db rebuild schema", () => { .values({ id: hostId, name: "host", - type: "persistent", lastSeenAt: now, createdAt: now, updatedAt: now, @@ -770,7 +761,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..2e89b86bfd 100644 --- a/packages/domain/src/app-settings.ts +++ b/packages/domain/src/app-settings.ts @@ -21,6 +21,19 @@ 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(), + defaultMachineAccess: z.string().min(1).nullable(), }) .strict(); export type AppSettings = z.infer; @@ -33,6 +46,8 @@ export const defaultAppSettings: AppSettings = { defaultProviderId: null, streamerMode: false, managedBranchPrefix: DEFAULT_MANAGED_BRANCH_PREFIX, + machineServerUrl: null, + defaultMachineAccess: null, }; export const appSettingsUpdateSchema = z.union([ diff --git a/packages/domain/src/environment.ts b/packages/domain/src/environment.ts index 0f4d69729e..85c51c7ab1 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..4543c9c8d0 100644 --- a/packages/domain/src/host.ts +++ b/packages/domain/src/host.ts @@ -1,18 +1,39 @@ import { z } from "zod"; +import { jsonValueSchema } from "./json-value.js"; 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 machineProviderSelectionSchema = z.object({ + inputs: jsonValueSchema.nullable(), +}); +export type MachineProviderSelection = z.infer< + typeof machineProviderSelectionSchema +>; + +export const machineLifecycleSchema = z.object({ + phase: z.enum(["active", "suspended", "retiring", "destroyed"]), + suspendedAt: z.number().nullable(), + retireAt: z.number().nullable(), + progress: z.string().nullable(), + teardown: z + .object({ + status: z.enum(["running", "failed", "removed"]), + attempt: z.number().int().nonnegative(), + message: z.string().optional(), + }) + .nullable(), +}); +export type MachineLifecycle = z.infer; + export const hostSchema = z.object({ id: z.string(), name: z.string(), - type: hostTypeSchema, status: hostStatusSchema, + machineProviderId: z.string().nullable(), + machineProviderSelection: machineProviderSelectionSchema.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 ffea585abf..ead62c2df4 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.50"; +export const PLUGIN_SDK_VERSION = "0.4.61"; 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 4982f94aa1..1494f3512a 100644 --- a/packages/domain/src/provider-event.ts +++ b/packages/domain/src/provider-event.ts @@ -686,6 +686,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/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..f304ed0f3f --- /dev/null +++ b/packages/domain/test/host.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from "vitest"; +import { hostSchema } from "../src/host.js"; + +describe("host contract", () => { + it("does not expose the deleted host type", () => { + expect("type" in hostSchema.shape).toBe(false); + }); +}); diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index f599aa6979..ace2322cd4 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -1,3 +1,9 @@ +import { + readinessInspectCommandSchema, + readinessInspectResultSchema, + readinessProbeCommandSchema, + readinessProbeResultSchema, +} from "./readiness.js"; import { desktopBrowserCommandSchemas, desktopBrowserResultSchemas, @@ -186,7 +192,12 @@ 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(), }) @@ -548,6 +559,7 @@ const projectCloneDefaultPathCommandSchema = z const projectCloneCommandSchema = z .object({ type: z.literal("project.clone"), + contributedEnv: z.array(hostDaemonContributedEnvEntrySchema).default([]), remoteUrl: z.string().min(1), projectSlug: z.string().min(1), targetPath: z.string().min(1).optional(), @@ -572,7 +584,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"]), @@ -590,6 +603,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, @@ -814,6 +828,7 @@ const providerHealthCommandSchema = z type: z.literal("provider.health"), providerId: z.string().min(1), bridgeLaunch: hostDaemonBridgeLaunchSchema, + contributedEnv: z.array(hostDaemonContributedEnvEntrySchema).optional(), cwd: z.string().min(1).optional(), }) .strict(); @@ -1759,6 +1774,24 @@ export const hostDaemonCommandRegistry = { flushEventsBeforeResult: false, envLane: null, }), + "workspace.readiness.inspect": defineHostDaemonCommandDescriptor({ + type: "workspace.readiness.inspect", + schema: readinessInspectCommandSchema, + resultSchema: readinessInspectResultSchema, + transport: "onlineRpc", + retryable: true, + flushEventsBeforeResult: false, + envLane: null, + }), + "host.readiness.probe": defineHostDaemonCommandDescriptor({ + type: "host.readiness.probe", + schema: readinessProbeCommandSchema, + resultSchema: readinessProbeResultSchema, + transport: "onlineRpc", + retryable: true, + flushEventsBeforeResult: false, + envLane: null, + }), "provider.installation.status": defineHostDaemonCommandDescriptor({ type: "provider.installation.status", schema: providerInstallationStatusCommandSchema, 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 147c69df06..0bf425d2d0 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 = 193 as const; +export const HOST_DAEMON_PROTOCOL_VERSION = 195 as const; export const HOST_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024; diff --git a/packages/host-daemon-contract/src/readiness.ts b/packages/host-daemon-contract/src/readiness.ts new file mode 100644 index 0000000000..466648f9f0 --- /dev/null +++ b/packages/host-daemon-contract/src/readiness.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; + +export const readinessInspectCommandSchema = z + .object({ + type: z.literal("workspace.readiness.inspect"), + path: z.string().min(1), + }) + .strict(); +const checkoutReadinessResultSchema = z + .object({ + commit: z.string(), + dirty: z.array(z.string()), + files: z.array(z.object({ path: z.string(), sha256: z.string() }).strict()), + abi: z.string(), + }) + .strict(); +export const readinessInspectResultSchema = z.union([ + checkoutReadinessResultSchema, + z + .object({ + kind: z.literal("directory"), + path: z.string().min(1), + hookSha256: z.string().nullable(), + }) + .strict(), +]); +export const readinessProbeCommandSchema = z + .object({ + type: z.literal("host.readiness.probe"), + serverPath: z.string().startsWith("/"), + headers: z.record(z.string(), z.string()), + }) + .strict(); +export const readinessProbeResultSchema = z + .object({ reachable: z.boolean(), status: z.number().int().nullable() }) + .strict(); diff --git a/packages/host-daemon-contract/src/session.ts b/packages/host-daemon-contract/src/session.ts index 5bc6e5847e..322329e70a 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< @@ -433,6 +431,8 @@ const hostDaemonOnlineRpcResponseSuccessSchema = z.discriminatedUnion( onlineRpcResponseSuccessSchemaFor("host.write_file"), onlineRpcResponseSuccessSchemaFor("provider.list_models"), onlineRpcResponseSuccessSchemaFor("provider.health"), + onlineRpcResponseSuccessSchemaFor("workspace.readiness.inspect"), + onlineRpcResponseSuccessSchemaFor("host.readiness.probe"), onlineRpcResponseSuccessSchemaFor("provider.installation.status"), onlineRpcResponseSuccessSchemaFor("provider.installation.run"), onlineRpcResponseSuccessSchemaFor("provider.usage"), @@ -509,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(), diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index 4daee42b05..8bd06a5f55 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -442,6 +442,13 @@ const ONLINE_RPC_RESPONSE_RESULT_FIXTURES: OnlineRpcResponseResultFixtures = { }, ], }, + "workspace.readiness.inspect": { + commit: "abc", + dirty: [], + files: [], + abi: "linux/x64/node-127", + }, + "host.readiness.probe": { reachable: true, status: 200 }, "workspace.status": WORKSPACE_UNAVAILABLE_RESULT, "workspace.diff": WORKSPACE_UNAVAILABLE_RESULT, "workspace.diffFiles": WORKSPACE_UNAVAILABLE_RESULT, @@ -669,6 +676,8 @@ const INTENTIONAL_OPTIONAL_HOST_DAEMON_FIELDS: Record = { "a tool_use approval's presentation carries a tint only when the bridge wants an accent colour; absence means the neutral row tint, which is not a colour value.", "hostDaemonInteractiveRequestSchema.interaction.payload.subject.presentation.title": "a tool_use approval's presentation has a title only when the call has a headline (a path, a query); absence means the label stands alone.", + "hostDaemonOnlineRpcCommandSchema.contributedEnv": + "Provider health may use an isolated effective turn environment; absence retains shared maintenance behavior.", "hostDaemonOnlineRpcCommandSchema.cwd": "provider.list_models may omit cwd when only user-level provider configuration applies.", "hostDaemonOnlineRpcCommandSchema.query": @@ -965,7 +974,7 @@ const CONTRIBUTED_ENV = [ describe("host-daemon command schemas", () => { it("uses the current host-daemon protocol version", () => { - expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(193); + expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(195); expect(HOST_ARTIFACT_MAX_BYTES).toBe(256 * 1024 * 1024); }); @@ -1070,11 +1079,9 @@ describe("host-daemon command schemas", () => { hostDaemonEnrollRequestSchema.parse({ hostId: "host_123", hostName: "test-host", - hostType: "persistent", }), ).toMatchObject({ hostId: "host_123", - hostType: "persistent", }); expect( @@ -2899,7 +2906,7 @@ describe("host-daemon session schemas", () => { hostDaemonEnrollRequestSchema.safeParse({ hostId: "host_123", hostName: "test-host", - hostType: "ephemeral", + hostType: "persistent", }).success, ).toBe(false); expect( @@ -2907,7 +2914,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", @@ -2923,7 +2930,6 @@ describe("host-daemon session schemas", () => { hostDaemonSessionOpenRequestSchema.parse({ hostId: "host_123", instanceId: "instance_1", - hostType: "persistent", hostName: "Michael's MacBook", hasMachineCredential: true, platform: "darwin", @@ -2938,7 +2944,6 @@ describe("host-daemon session schemas", () => { }), ).toMatchObject({ hostId: "host_123", - hostType: "persistent", hasMachineCredential: true, loadedEnvironments: [], }); @@ -2948,7 +2953,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", @@ -2974,7 +2978,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", @@ -2993,7 +2996,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", @@ -3010,7 +3012,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", @@ -3702,6 +3703,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..4307e68a05 100644 --- a/packages/host-watcher/test/workspace-root-ignores.test.ts +++ b/packages/host-watcher/test/workspace-root-ignores.test.ts @@ -122,6 +122,19 @@ async function measureWorkspaceRootWatch(root: string): Promise<{ } } +async function waitFor( + predicate: () => boolean, + timeoutMs: number, +): Promise { + const deadline = Date.now() + timeoutMs; + while (!predicate()) { + if (Date.now() > deadline) { + throw new Error("Timed out waiting for workspace change events"); + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } +} + afterEach(async () => { vi.restoreAllMocks(); for (const dir of tempDirs.splice(0)) { @@ -212,14 +225,11 @@ describe("workspace root watch events inside nested heavy directories (#1779)", "module.exports={changed:true}\n", ); await fs.writeFile(nestedGitFile, "marker\n"); - await vi.waitFor( - async () => { - await fs.writeFile(visibleFile, `visible ${Date.now()}\n`); - expect( - events.some((event) => event.changedPaths.includes(visibleFile)), - ).toBe(true); - }, - { timeout: EVENT_TIMEOUT_MS, interval: 100 }, + await fs.writeFile(visibleFile, "visible\n"); + await waitFor( + () => + events.some((event) => event.changedPaths.includes(visibleFile)), + EVENT_TIMEOUT_MS, ); await new Promise((resolve) => setTimeout(resolve, 300)); @@ -230,9 +240,11 @@ describe("workspace root watch events inside nested heavy directories (#1779)", expect( changedPaths.filter( (changedPath) => - changedPath.startsWith(path.join(realRoot, "apps")) && - (changedPath.includes(`${path.sep}node_modules${path.sep}`) || - changedPath.includes(`${path.sep}.git${path.sep}`)), + changedPath.includes(`${path.sep}node_modules${path.sep}`) || + path + .relative(realRoot, changedPath) + .split(path.sep) + .indexOf(".git") > 0, ), ).toEqual([]); } finally { diff --git a/packages/machine-ssh/configuration.ts b/packages/machine-ssh/configuration.ts new file mode 100644 index 0000000000..cee2a1cf13 --- /dev/null +++ b/packages/machine-ssh/configuration.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +export function isSafeSshDestination(value: string): boolean { + const trimmed = value.trim(); + if ( + trimmed.length === 0 || + trimmed.length > 255 || + trimmed.startsWith("-") || + /[\x00-\x20\x7f;&|$`'"\\(){}<>!?#~]/u.test(trimmed) + ) { + return false; + } + const parts = trimmed.split("@"); + if (parts.length > 2) return false; + const host = parts.at(-1) ?? ""; + const user = parts.length === 2 ? parts[0] : null; + if (user !== null && !/^[A-Za-z0-9_][A-Za-z0-9._-]*$/u.test(user)) { + return false; + } + return ( + /^[A-Za-z0-9][A-Za-z0-9._:+%=-]*$/u.test(host) || + /^\[[0-9A-Fa-f:.]+\]$/u.test(host) + ); +} + +export const sshDestinationSchema = z + .string() + .trim() + .refine( + isSafeSshDestination, + "Enter an SSH host alias or user@host without spaces or shell metacharacters.", + ); diff --git a/packages/machine-ssh/package.json b/packages/machine-ssh/package.json new file mode 100644 index 0000000000..c97c88091f --- /dev/null +++ b/packages/machine-ssh/package.json @@ -0,0 +1,24 @@ +{ + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc --noEmit", + "lint": "oxlint *.ts" + }, + "dependencies": { + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vitest": "^4.1.1" + }, + "name": "bb-machine-ssh", + "exports": { + "./configuration": "./configuration.ts", + "./ssh-runner": "./ssh-runner.ts", + "./uninstall": "./uninstall.ts" + } +} diff --git a/packages/machine-ssh/ssh-runner.test.ts b/packages/machine-ssh/ssh-runner.test.ts new file mode 100644 index 0000000000..52ee11ae71 --- /dev/null +++ b/packages/machine-ssh/ssh-runner.test.ts @@ -0,0 +1,145 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { createSshRunner } from "./ssh-runner.js"; + +const directories: string[] = []; +afterEach(async () => { + await Promise.all( + directories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function fixture(body: string) { + await mkdir("/tmp/pr2", { recursive: true }); + const directory = await mkdtemp("/tmp/pr2/ssh-executor-"); + directories.push(directory); + const executable = join(directory, "ssh"); + await writeFile(executable, `#!${process.execPath}\n${body}`, { + mode: 0o755, + }); + return createSshRunner(executable); +} + +const request = () => ({ + command: ["printf", "%s", "hello"], + timeoutMs: 5_000, + signal: new AbortController().signal, +}); + +describe("plain SSH executor", () => { + it("quotes argv and keeps secret stdin out of the remote command", async () => { + const runner = await fixture( + 'let input="";process.stdin.on("data",chunk=>input+=chunk);process.stdin.on("end",()=>process.stdout.write(JSON.stringify({args:process.argv.slice(2),input})));', + ); + const result = await runner.exec("user@host", { + ...request(), + command: ["printf", "%s", "a'b $(touch /bad)\nnext"], + stdin: "credential", + }); + const received = JSON.parse(result.stdout); + expect(received.input).toBe("credential"); + expect(received.args.at(-1)).toMatch( + /^exec "\$\{SHELL:-\/bin\/sh\}" -lc /u, + ); + expect(received.args).toEqual( + expect.arrayContaining([ + "-T", + "BatchMode=yes", + "StrictHostKeyChecking=yes", + "ClearAllForwardings=yes", + "ControlPath=none", + "--", + "user@host", + ]), + ); + expect(received.args.join(" ")).not.toContain("credential"); + }); + it("round-trips literal argv and stdin through the remote login shell", async () => { + const runner = await fixture(` + const { spawn } = require("node:child_process"); + const child = spawn("/bin/sh", ["-c", process.argv.at(-1)], { + env: { ...process.env, SHELL: "/bin/sh" }, + stdio: ["pipe", "pipe", "pipe"], + }); + process.stdin.pipe(child.stdin); + child.stdout.pipe(process.stdout); + child.stderr.pipe(process.stderr); + child.on("close", code => process.exit(code ?? 1)); + `); + const argument = 'a\'b $(printf INJECTED)\nnext; "quoted"'; + const result = await runner.exec("box", { + ...request(), + command: [ + process.execPath, + "-e", + 'let input="";process.stdin.on("data",chunk=>input+=chunk);process.stdin.on("end",()=>process.stdout.write(JSON.stringify({argument:process.argv[1],input})));', + argument, + ], + stdin: "private bootstrap payload", + }); + expect(result.exitCode).toBe(0); + expect(JSON.parse(result.stdout)).toEqual({ + argument, + input: "private bootstrap payload", + }); + }); + it("returns stderr and nonzero remote exit codes", async () => { + const runner = await fixture( + 'process.stdout.write("out");process.stderr.write("denied");process.exitCode=17;', + ); + await expect(runner.exec("box", request())).resolves.toEqual({ + exitCode: 17, + stdout: "out", + stderr: "denied", + }); + }); + it("terminates a hung command at its deadline", async () => { + const runner = await fixture("setInterval(()=>{},1000);"); + await expect( + runner.exec("box", { ...request(), timeoutMs: 50 }), + ).rejects.toThrow("timed out"); + }); + it("cancels a running command", async () => { + const runner = await fixture("setInterval(()=>{},1000);"); + const controller = new AbortController(); + const pending = runner.exec("box", { + ...request(), + signal: controller.signal, + }); + controller.abort(); + await expect(pending).rejects.toThrow("cancelled"); + }); + it("refuses a pre-aborted command before spawn", async () => { + const controller = new AbortController(); + controller.abort(); + await expect( + createSshRunner("/missing").exec("box", { + ...request(), + signal: controller.signal, + }), + ).rejects.toBeDefined(); + }); + it("bounds combined output", async () => { + const runner = await fixture( + 'process.stdout.write("x".repeat(600000));process.stderr.write("y".repeat(600000));setInterval(()=>{},1000);', + ); + await expect(runner.exec("box", request())).rejects.toThrow( + "exceeded 1 MiB", + ); + }); + it("reports a missing executable", async () => { + const runner = createSshRunner("/missing-ssh-executable"); + await expect(runner.available()).resolves.toBe(false); + await expect(runner.exec("box", request())).rejects.toMatchObject({ + code: "ENOENT", + }); + }); + it("rejects option injection before spawn", async () => { + await expect( + createSshRunner("/missing").exec("-oProxyCommand=id", request()), + ).rejects.toThrow("Enter an SSH host"); + }); +}); diff --git a/packages/machine-ssh/ssh-runner.ts b/packages/machine-ssh/ssh-runner.ts new file mode 100644 index 0000000000..f4324e7e29 --- /dev/null +++ b/packages/machine-ssh/ssh-runner.ts @@ -0,0 +1,127 @@ +import { spawn } from "node:child_process"; +import { sshDestinationSchema } from "./configuration.js"; + +const MAX_OUTPUT_BYTES = 1024 * 1024; + +export interface SshExecRequest { + command: string[]; + timeoutMs: number; + signal: AbortSignal; + stdin?: string; +} + +export interface SshExecResult { + exitCode: number; + stdout: string; + stderr: string; +} + +export interface SshRunner { + available(): Promise; + exec(target: string, request: SshExecRequest): Promise; +} + +function shellQuote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +export function createSshRunner(executable = "ssh"): SshRunner { + return { + available() { + return new Promise((resolve) => { + const child = spawn(executable, ["-V"], { + stdio: "ignore", + timeout: 5_000, + }); + child.once("error", () => resolve(false)); + child.once("close", (code) => resolve(code === 0)); + }); + }, + async exec(destination, request) { + const target = sshDestinationSchema.parse(destination); + request.signal.throwIfAborted(); + if ( + request.command.length === 0 || + request.command[0].length === 0 || + request.command.some((argument) => argument.includes("\0")) + ) { + throw new Error("SSH command must contain a program and no NUL bytes."); + } + if (!Number.isSafeInteger(request.timeoutMs) || request.timeoutMs <= 0) { + throw new Error( + "SSH timeout must be a positive integer in milliseconds.", + ); + } + return new Promise((resolve, reject) => { + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + let outputBytes = 0; + let failure: Error | null = null; + const child = spawn( + executable, + [ + "-T", + "-o", + "BatchMode=yes", + "-o", + "StrictHostKeyChecking=yes", + "-o", + "ClearAllForwardings=yes", + "-o", + "ControlMaster=no", + "-o", + "ControlPath=none", + "--", + target, + `exec "\${SHELL:-/bin/sh}" -lc ${shellQuote(`exec ${request.command.map(shellQuote).join(" ")}`)}`, + ], + { stdio: ["pipe", "pipe", "pipe"] }, + ); + function stop(error: Error) { + failure ??= error; + child.kill("SIGKILL"); + } + const abort = () => stop(new Error("SSH operation cancelled")); + request.signal.addEventListener("abort", abort, { once: true }); + if (request.signal.aborted) abort(); + const timeout = setTimeout( + () => + stop( + new Error(`SSH command timed out after ${request.timeoutMs}ms.`), + ), + request.timeoutMs, + ); + function collect(chunks: Buffer[], chunk: Buffer) { + outputBytes += chunk.length; + if (outputBytes > MAX_OUTPUT_BYTES) { + stop(new Error("SSH command output exceeded 1 MiB.")); + } else { + chunks.push(chunk); + } + } + child.stdout.on("data", (chunk: Buffer) => collect(stdout, chunk)); + child.stderr.on("data", (chunk: Buffer) => collect(stderr, chunk)); + child.once("error", (error) => { + failure ??= error; + }); + child.once("close", (code) => { + clearTimeout(timeout); + request.signal.removeEventListener("abort", abort); + if (failure !== null) reject(failure); + else + resolve({ + exitCode: code ?? 1, + stdout: Buffer.concat(stdout).toString("utf8"), + stderr: Buffer.concat(stderr).toString("utf8"), + }); + }); + child.stdin.on("error", (error: NodeJS.ErrnoException) => { + if (error.code !== "EPIPE") stop(error); + }); + child.stdin.end(request.stdin); + }); + }, + }; +} + +export const openSshRunner = createSshRunner(); diff --git a/packages/machine-ssh/tsconfig.json b/packages/machine-ssh/tsconfig.json new file mode 100644 index 0000000000..0c223aa725 --- /dev/null +++ b/packages/machine-ssh/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"] + }, + "include": ["*.ts", "*.tsx"] +} diff --git a/packages/machine-ssh/uninstall.test.ts b/packages/machine-ssh/uninstall.test.ts new file mode 100644 index 0000000000..28265411a4 --- /dev/null +++ b/packages/machine-ssh/uninstall.test.ts @@ -0,0 +1,83 @@ +import { execFile } from "node:child_process"; +import { + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { uninstallCommand } from "./uninstall.js"; + +const exec = promisify(execFile); +const homes: string[] = []; +afterEach(async () => { + await Promise.all( + homes.splice(0).map((home) => rm(home, { recursive: true, force: true })), + ); +}); + +async function fixture() { + await mkdir("/tmp/pr2", { recursive: true }); + const home = await mkdtemp("/tmp/pr2/ssh-checkpoint-uninstall-"); + homes.push(home); + await mkdir(join(home, ".bb")); + await writeFile(join(home, ".bb", "unrelated"), "leave this instance alone"); + await mkdir(join(home, ".local", "bin"), { recursive: true }); + return { + home, + shim: join(home, ".local", "bin", "bb"), + async run(hostId = "host_reserved") { + const [command, ...args] = uninstallCommand(hostId); + return exec(command, args, { + env: { HOME: home, PATH: "/usr/bin:/bin" }, + timeout: 5_000, + }); + }, + }; +} + +describe("SSH checkpoint removal", () => { + it("succeeds without touching an unrelated instance when the early shim is absent", async () => { + const f = await fixture(); + expect(await f.run()).toMatchObject({ stdout: "", stderr: "" }); + expect(await readFile(join(f.home, ".bb", "unrelated"), "utf8")).toBe( + "leave this instance alone", + ); + }); + it("delegates the reserved identity to core's ownership-checked lifecycle", async () => { + const f = await fixture(); + await writeFile( + f.shim, + '#!/bin/sh\nprintf "%s\\n" "$@" > "$HOME/arguments"\n', + { mode: 0o755 }, + ); + await f.run(); + expect(await readFile(join(f.home, "arguments"), "utf8")).toBe( + "machine\nuninstall\n--host-id\nhost_reserved\n", + ); + }); + it("preserves an ownership refusal for cleanup retries", async () => { + const f = await fixture(); + await writeFile( + f.shim, + '#!/bin/sh\nprintf "%s\\n" "ownership mismatch" >&2\nexit 2\n', + { mode: 0o755 }, + ); + await expect(f.run()).rejects.toMatchObject({ + code: 2, + stderr: "ownership mismatch\n", + }); + expect(await readFile(join(f.home, ".bb", "unrelated"), "utf8")).toBe( + "leave this instance alone", + ); + }); + it("does not mistake a broken lifecycle shim for an installation that never began", async () => { + const f = await fixture(); + await symlink(join(f.home, "missing-cli"), f.shim); + await expect(f.run()).rejects.toMatchObject({ code: expect.any(Number) }); + }); +}); diff --git a/packages/machine-ssh/uninstall.ts b/packages/machine-ssh/uninstall.ts new file mode 100644 index 0000000000..b7088ab1f9 --- /dev/null +++ b/packages/machine-ssh/uninstall.ts @@ -0,0 +1,12 @@ +export function uninstallCommand(hostId: string): string[] { + return [ + "sh", + "-c", + [ + 'if [ ! -e "$HOME/.local/bin/bb" ] && [ ! -L "$HOME/.local/bin/bb" ]; then exit 0; fi', + 'exec "$HOME/.local/bin/bb" machine uninstall --host-id "$1"', + ].join("\n"), + "bb", + hostId, + ]; +} diff --git a/packages/machine-ssh/vitest.config.ts b/packages/machine-ssh/vitest.config.ts new file mode 100644 index 0000000000..114c109f02 --- /dev/null +++ b/packages/machine-ssh/vitest.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from "node:url"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; + +export default defineWorkspaceTestConfig({ + test: { + silent: "passed-only", + projects: sharedWorkerProjects({ + pkgDir: fileURLToPath(new URL(".", import.meta.url)), + name: "bb-machine-ssh", + include: ["**/*.test.ts", "**/*.test.tsx"], + }), + }, +}); diff --git a/packages/plugin-api-map/src/surfaces.ts b/packages/plugin-api-map/src/surfaces.ts index 1d5275ccf7..ab32f88c22 100644 --- a/packages/plugin-api-map/src/surfaces.ts +++ b/packages/plugin-api-map/src/surfaces.ts @@ -552,6 +552,8 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "thread-events", "dispatch-hook", "environment-providers", + "machine-providers", + "server-access", "host-workers", ], }, @@ -581,8 +583,15 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Be invoked the same way by a person at a terminal and by an agent mid-task", "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", + "Stream bounded pages with experimental_continue; disconnecting a reader leaves durable jobs running", + "Reject stale RPC revisions with experimental_PluginRpcConflict and HTTP 409", + ], + apiSymbols: [ + "PluginCli", + "PluginCliResult", + "experimental_PluginCliContinuation", + "experimental_PluginRpcConflict", ], - apiSymbols: ["PluginCli"], firstParty: [ "Automations", "Custom instructions", @@ -737,6 +746,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "Declare what it needs from the request as a zod inputs schema; bb parses the request with it before the thread exists, publishes it as JSON Schema for the CLI, and hands create the parsed value as inputs", "Validate a resolved selection once before thread creation; host-dependent preflight requires connectivity, and create checks conditions that can change afterward", "Read the facts as typed values on the create context: host is always non-null, while projectCheckout and gitRemote are non-null exactly when required", + "Read projectCheckout.experimental_ownsPath to distinguish core clones from user-maintained attachments; core readiness consumes the environment hook outcome", "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 terminal/transient failure; core owns attempts and retry behavior; provider policy exposes only retirement grace and path-key strategy", @@ -757,7 +767,7 @@ export const SURFACE_GROUPS: SurfaceGroup[] = [ "PluginEnvironmentProviderInputsProps", "PluginEnvironmentProviderInputsChange", "experimental_BranchPicker", - "BranchPickerProps", + "ExperimentalBranchPickerProps", "experimental_useBranches", "UseBranchesArgs", "BranchesState", @@ -779,6 +789,108 @@ 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 and an optional glyph, plugin-relative SVG, declared icon, or React icon; omit it to make created machines look like ordinary enrolled machines", + "Declare a git-remote requirement, Standard Schema inputs, availability and validation; core parses and persists inputs before create", + "Keep secrets in plugin settings because persisted machine inputs are readable by every plugin; pass only non-secret configuration or references", + "Expose optional picker sugar that creates the machine and then asks one environment provider for the checkout; without environmentRow, thread --new-machine requires --environment-provider", + "Create standalone machines with a nullable project for the Machines page and bb SDK, without promising that a project source was enrolled", + "Make create idempotent by its durable key so a restart after enrolment recovers the same machine", + "Prepare versioned single-use enrollment bundles with enrollments.prepare (also prepareEnrollment), waitForConnection and cancel; keys retain host identity across retries", + "Compose synchronous installerCommand argv plus private stdin, or bootstrap over a MachineExecutor; never put credentials in resource JSON or output", + "Bootstrap preinstalled or installed daemons and restart an enrolled identity after snapshot restore", + "Stream progress 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; manual enrollment commands are fetched transiently by authorized followers", + "Prepare an encrypted v2 enrollment bundle with optional request headers; core upgrades pending v1 bundles on preparation", + "Prepare enrollment, then await create.checkpoint(resource) immediately after allocation so cancellation can remove it without waiting for bootstrap; never checkpoint the bootstrap bundle", + "Return a failed create with allocation: none only for definitive rejection before allocation; core skips allocation reconciliation and settles enrollment immediately", + "Implement experimental_reconcileCleanup to discover and remove uncertain allocations by durable key or metadata, never create or bootstrap; return failed while allocation intent is unresolved so core retries after removeRetryMs", + "Checkpoint a recoverable private resource during suspend 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", + "Resolve per-machine idle timeouts with experimental_idleSuspendMs; core checks activity and terminals and persists the empty-machine idle baseline", + "Publish inventory and estimates through experimental_details; read them with bb.sdk.hosts.experimental_providerDetails, machine rows/details and bb machine show --json", + + "Declare experimental_observe and experimental_policy to reconcile vendor deadlines and live per-machine policy without allocation; core fences preservation and retention", + "Report a successful filesystem save through suspend.checkpoint(resource, experimental_snapshotAt) before termination; inspect or keep through bb.sdk.hosts.experimental_lifecycle", + "Call bb.sdk.hosts.experimental_ensureReady for CLI, credential-route reachability and checkout checks before dispatch", + "Credential health may supply an experimental_probe for authenticated machine-to-proxy reachability without exposing its headers to clients", + "Optionally declare suspend and resume together; core suspends after idle and resumes on the next send", + "Choose last-thread plus grace retirement with environment-removal cascade, or never retirement with explicit user removal", + "Return an opaque private JSON resource that core persists and passes back to lifecycle operations", + "Render inputs in both the picker sugar row and Add machine with app.slots.experimental_machineProviderInputs; PluginMachineProviderInputsProps.experimental_agentProviderId identifies the selected composer agent", + ], + apiSymbols: [ + "PluginMachines", + "EnrollmentBootstrap", + "MachineEnrollment", + "MachineExecutorRequest", + "MachineExecutor", + "MachineEnrollmentRequest", + "MachineConnectionRequest", + "MachineEnrollments", + "MachineBootstrapRequest", + "MachineInstallerCommand", + "MachineBootstrapApi", + "PluginMachineProviderDeclaration", + "PluginMachineProviderRequirements", + "PluginMachineValidateDecision", + "PluginMachineProviderInputsRegistration", + "PluginMachineProviderInputsProps", + "PluginMachineProviderInputsChange", + "PluginMachineProviderDefinition", + "PluginMachineProviderInputsSchema", + "PluginMachineProviderPolicy", + "PluginMachineProviderEnvironmentRow", + "PluginMachineProviderAvailabilityContext", + "PluginMachineProviderAvailability", + "PluginMachineProviderValidateContext", + "PluginMachineProviderCreateContext", + "PluginMachineProviderCreateResult", + "PluginMachineProviderLifecycleContext", + "PluginMachineProviderSuspendContext", + "PluginMachineProviderResumeContext", + "PluginMachineProviderProgress", + "PluginMachineProviderResourceResult", + "PluginMachineProviderRemoveResult", + ], + firstParty: [ + "Modal sandbox", + "DigitalOcean", + "SSH machine", + "Tailscale", + ], + experimental: true, + }, + { + id: "server-access", + tagline: "Connect machines to their server", + title: "Machine server access", + summary: + "Registers server access for enrolment and ongoing machine runtime requests. With this, a plugin can:", + bullets: [ + "Register bb.experimental_serverAccess with availability, idempotent acquire and release", + "Return a user-safe experimental_attention diagnostic for General settings without disabling healthy access", + "Return { id, serverUrl, headers? }; machines attach headers to all server requests without provider-specific redemption", + "Choose a General default or override access for a machine; automatic selection prefers paired bb Cloud then a configured direct URL", + "Use the Server URL reachable by machines setting or BB_EXTERNAL_URL fallback; the URL is not a reachability guarantee", + "Use an experimental_ServerAccessRecoveryError for a user-safe recovery message; persist acquisition intent and keep credentials in secret storage; release receives key, hostId and a nullable grantId to reconcile interrupted acquisitions before enrollment", + ], + apiSymbols: [ + "PluginServerAccess", + "ServerAccessProviderDeclaration", + "ServerAccessGrant", + "ServerAccessSelection", + ], + firstParty: ["Connect", "Tailscale"], + experimental: true, + }, { id: "host-workers", tagline: "Run code on enrolled machines", @@ -843,7 +955,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 control provider-managed machine lifecycles", "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..4051545185 100644 --- a/packages/plugin-api-map/test/surfaces.test.ts +++ b/packages/plugin-api-map/test/surfaces.test.ts @@ -208,6 +208,53 @@ describe("surface card copy", () => { expect(eventCopy).toContain("cancelled before dispatch"); }); + it("documents durable machine suspension checkpoints", () => { + const machineProviders = SURFACES_BY_ID.get("machine-providers"); + expect(machineProviders?.apiSymbols).toContain( + "PluginMachineProviderSuspendContext", + ); + expect(machineProviders?.bullets.join(" ")).toContain( + "Checkpoint a recoverable private resource during suspend", + ); + }); + + it("maps enrollment helpers and checkpointed allocation to the machine surface", () => { + const machines = SURFACES_BY_ID.get("machine-providers"); + expect(machines?.apiSymbols).toEqual( + expect.arrayContaining([ + "EnrollmentBootstrap", + "MachineEnrollment", + "MachineExecutorRequest", + "MachineExecutor", + "MachineEnrollmentRequest", + "MachineConnectionRequest", + "MachineEnrollments", + "MachineBootstrapRequest", + "MachineInstallerCommand", + "MachineBootstrapApi", + "PluginMachineProviderCreateContext", + "PluginMachineProviderInputsProps", + "PluginMachineProviderInputsChange", + "PluginMachineProviderInputsRegistration", + ]), + ); + expect(machines?.bullets.join(" ")).toContain( + "await create.checkpoint(resource)", + ); + expect(machines?.bullets.join(" ")).toContain( + "never checkpoint the bootstrap bundle", + ); + expect(machines?.bullets.join(" ")).toContain("--environment-provider"); + expect(SURFACES_BY_ID.get("server-access")?.apiSymbols).toEqual( + expect.arrayContaining([ + "PluginServerAccess", + "ServerAccessProviderDeclaration", + "ServerAccessGrant", + "ServerAccessSelection", + ]), + ); + }); + it("follows the lead-then-bullets template", () => { for (const group of SURFACE_GROUPS) { for (const surface of group.surfaces) { diff --git a/packages/plugin-build/src/build-plugin-host.ts b/packages/plugin-build/src/build-plugin-host.ts index 9a234ede99..14149f8058 100644 --- a/packages/plugin-build/src/build-plugin-host.ts +++ b/packages/plugin-build/src/build-plugin-host.ts @@ -42,6 +42,13 @@ export function experimental_defineHostEntry(args) { const PLUGIN_SDK_ROOT_RUNTIME = ` export const PLUGIN_CLI_OUTPUT_MAX_BYTES = 1024 * 1024; export function defineRpcContract(contract) { return contract; } +export class experimental_PluginRpcConflict extends Error { + constructor(message, latestRevision) { + super(message); + this.name = "experimental_PluginRpcConflict"; + this.latestRevision = latestRevision; + } +} ${PLUGIN_SDK_DEFINE_HOST_ENTRY_RUNTIME}`; const PLUGIN_SDK_HOST_SUBPATH = "./host"; diff --git a/packages/plugin-sdk/README.md b/packages/plugin-sdk/README.md index 5c802cab0e..507a285d85 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 74b848b8f9..96bc2ad6d3 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.50", + "version": "0.4.61", "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 c115cf5ba1..c798676237 100644 --- a/packages/plugin-sdk/scripts/build-bundled-dts.mjs +++ b/packages/plugin-sdk/scripts/build-bundled-dts.mjs @@ -75,6 +75,10 @@ const outputs = { pkgRoot, "src/environment-provider.ts", ), + "bb-plugin-sdk-machine-provider.d.ts": path.join( + pkgRoot, + "src/machine-provider.ts", + ), "bb-plugin-sdk-internal-composer-customization-validation.d.ts": path.join( pkgRoot, "src/internal/composer-customization-validation.ts", diff --git a/packages/plugin-sdk/scripts/build-runtime.mjs b/packages/plugin-sdk/scripts/build-runtime.mjs index 6a77630064..fab3610e75 100644 --- a/packages/plugin-sdk/scripts/build-runtime.mjs +++ b/packages/plugin-sdk/scripts/build-runtime.mjs @@ -77,6 +77,11 @@ const entries = [ output: "dist/environment-provider.js", external: ["zod", "zod/*"], }, + { + source: "src/machine-provider.ts", + output: "dist/machine-provider.js", + external: ["zod", "zod/*"], + }, { source: "src/internal/composer-customization-validation.ts", output: "dist/internal/composer-customization-validation.js", diff --git a/packages/plugin-sdk/src/__tests__/package-exports.test.ts b/packages/plugin-sdk/src/__tests__/package-exports.test.ts index 4231b3fee6..9914ee5948 100644 --- a/packages/plugin-sdk/src/__tests__/package-exports.test.ts +++ b/packages/plugin-sdk/src/__tests__/package-exports.test.ts @@ -28,6 +28,7 @@ describe("packed plugin SDK exports", () => { "./provider-bridge/testing", "./provider-bridge/acp", "./environment-provider", + "./machine-provider", "./app", "./host", "./internal/composer-customization-validation", diff --git a/packages/plugin-sdk/src/__tests__/public-types.test.ts b/packages/plugin-sdk/src/__tests__/public-types.test.ts index ceb61c3996..ed575d433d 100644 --- a/packages/plugin-sdk/src/__tests__/public-types.test.ts +++ b/packages/plugin-sdk/src/__tests__/public-types.test.ts @@ -10,6 +10,8 @@ type ExpectedBbPluginApiKey = | "experimental_aiServices" | "experimental_environments" | "experimental_hooks" + | "experimental_machines" + | "experimental_serverAccess" | "hosts" | "http" | "log" @@ -27,6 +29,7 @@ type ExpectedBbPluginApiKey = const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [ "BbPluginApi", + "experimental_PluginCliContinuation", "MessageDispatchHookContext", "MessageDispatchHookDecision", "PluginAgents", @@ -77,6 +80,10 @@ const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [ "PluginInteractionResult", "PluginKvStorage", "PluginLogger", + "PluginMachineProviderDeclaration", + "PluginMachineProviderRequirements", + "PluginMachineValidateDecision", + "PluginMachines", "PluginMentionItem", "PluginMentionProviderRegistration", "PluginMentionSearchContext", @@ -117,6 +124,10 @@ const EXPECTED_BACKEND_ROOT_TYPE_EXPORTS = [ "PluginThreadEventPayloads", "PluginTurnFailedEvent", "PluginUi", + "PluginServerAccess", + "ServerAccessGrant", + "ServerAccessProviderDeclaration", + "ServerAccessSelection", ] as const; const EXPECTED_BACKEND_ROOT_VALUE_EXPORTS = [ @@ -140,7 +151,10 @@ const EXPECTED_RPC_ROOT_TYPE_EXPORTS = [ "StandardSchemaV1Result", ] as const; -const EXPECTED_RPC_ROOT_VALUE_EXPORTS = ["defineRpcContract"] as const; +const EXPECTED_RPC_ROOT_VALUE_EXPORTS = [ + "defineRpcContract", + "experimental_PluginRpcConflict", +] as const; const EXPECTED_HOST_ROOT_TYPE_EXPORTS = [ "ExperimentalHostCallOptions", diff --git a/packages/plugin-sdk/src/app-contract.ts b/packages/plugin-sdk/src/app-contract.ts index 238f5309a8..f3afaeb798 100644 --- a/packages/plugin-sdk/src/app-contract.ts +++ b/packages/plugin-sdk/src/app-contract.ts @@ -1411,7 +1411,8 @@ 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. + * The machine the selection names, for a provider that requires `host`; + * null before one is picked or for a provider that runs without one. */ hostId: string | null; /** @@ -1447,6 +1448,37 @@ 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 { + /** Selected composer agent, or null outside a composer; older hosts may omit this field. */ + experimental_agentProviderId?: string | null; + /** Project selected in the composer; null outside a project. */ + projectId: string | null; + /** 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 // --------------------------------------------------------------------------- @@ -1523,7 +1555,7 @@ export interface PluginAppSlots { registration: PluginCommandPaletteActionRegistration, ): void; /** - * Draw one agent or environment provider's icon with an inline + * Draw one agent, environment, or machine provider's icon with an inline * React component instead of its ``-rendered logo file (see * {@link PluginProviderIconRegistration}). Experimental: see * docs/api_to_audit.md. @@ -1547,6 +1579,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 { @@ -2003,7 +2043,7 @@ export interface ExperimentalProviderModelPickerProps { * The host owns fetching, searching, and refreshing the branch list; the * caller owns only the selection. */ -export interface BranchPickerProps { +export interface ExperimentalBranchPickerProps { /** * The enrolled machine whose project checkout supplies the branch list. * Null renders the picker disabled with no options. @@ -2499,11 +2539,11 @@ export interface PluginSdkApp { experimental_PermissionModePicker: ComponentType; /** * BB's branch picker with its branch-options loading for one host and - * project (see {@link BranchPickerProps}) — the same control + * project (see {@link ExperimentalBranchPickerProps}) — the same control * the New Thread composer renders as "Branch from". Experimental: see * docs/api_to_audit.md. */ - experimental_BranchPicker: ComponentType; + experimental_BranchPicker: ComponentType; /** * Search and refresh the branch list for one project source. Experimental: * see docs/api_to_audit.md. diff --git a/packages/plugin-sdk/src/backend-contract.ts b/packages/plugin-sdk/src/backend-contract.ts index 1148dedbbe..43d80b0b43 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"; @@ -410,6 +411,74 @@ export interface PluginEnvironments { recheck(): Promise; } +export interface PluginMachineProviderRequirements { + gitRemote?: boolean; +} + +export type PluginMachineValidateDecision = + | { action: "accept" } + | { action: "refuse"; message: string }; + +export type PluginMachineProviderDeclaration< + Requires extends PluginMachineProviderRequirements = + PluginMachineProviderRequirements, + Inputs extends + import("./machine-provider.js").PluginMachineProviderInputsSchema = + import("./machine-provider.js").PluginMachineProviderInputsSchema, +> = import("./machine-provider.js").PluginMachineProviderDefinition< + Requires, + Inputs +>; + +export interface ServerAccessGrant { + id: string; + serverUrl: string; + headers?: Record; +} + +export interface ServerAccessSelection { + providerId: string; +} + +export interface ServerAccessProviderDeclaration { + /** A deliberate user-safe diagnostic shown in General settings without changing availability. Return null when no attention is needed. */ + experimental_attention?(): string | null | Promise; + id: string; + displayName: string; + availability(): + | import("./machine-provider.js").PluginMachineProviderAvailability + | Promise< + import("./machine-provider.js").PluginMachineProviderAvailability + >; + /** Throw an Error named experimental_ServerAccessRecoveryError to expose a deliberate user-safe recovery message. Ordinary failures are redacted. */ + 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; +} + +export interface PluginMachines extends MachineBootstrapApi { + register< + const Requires extends PluginMachineProviderRequirements, + 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 @@ -421,7 +490,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; }; @@ -817,7 +892,14 @@ export interface PluginInteractionRequest { timeoutMs?: number; } +export interface experimental_PluginCliContinuation { + argv: string[]; + delayMs: number; +} + export interface PluginCliResult { + /** Print this page, then request the next page until interrupted. */ + experimental_continue?: experimental_PluginCliContinuation; exitCode: number; stdout?: string; stderr?: string; @@ -840,6 +922,7 @@ export interface PluginCliOutputLimitError { /** Normalized host result returned by the plugin CLI HTTP/testing boundary. */ export interface PluginCliExecutionResult { + experimental_continue?: experimental_PluginCliContinuation; exitCode: number; stdout: string; stderr: string; @@ -1531,9 +1614,11 @@ export interface ExperimentalPluginProviderEnvEntry { export interface ExperimentalPluginProviderEnvHealthContext { hostId: string; + experimental_readiness?: { threadId: string | null }; } export interface ExperimentalPluginProviderEnvHealth { + experimental_probe?: { serverPath: string; headers: Record }; label: string; statusMessage: string; } @@ -1796,6 +1881,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 df68d3dad5..602340721a 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; @@ -42,7 +42,7 @@ export interface PluginEnvironmentProviderValidateContext< export interface PluginEnvironmentProviderAvailabilityContext { project: Project; host: Host | null; - projectCheckout: { path: string } | null; + projectCheckout: { path: string; experimental_ownsPath?: boolean } | null; gitRemote: string | null; } diff --git a/packages/plugin-sdk/src/index.ts b/packages/plugin-sdk/src/index.ts index fc6baecfe2..064a525dbc 100644 --- a/packages/plugin-sdk/src/index.ts +++ b/packages/plugin-sdk/src/index.ts @@ -19,3 +19,5 @@ export type { ExperimentalDesktopBrowserCreateInput, ExperimentalDesktopBrowserAcquireInput, } from "@bb/sdk"; + +export type * from "./machine-bootstrap.js"; diff --git a/packages/plugin-sdk/src/internal/host-policy.ts b/packages/plugin-sdk/src/internal/host-policy.ts index 3eafffb11c..302217525d 100644 --- a/packages/plugin-sdk/src/internal/host-policy.ts +++ b/packages/plugin-sdk/src/internal/host-policy.ts @@ -24,6 +24,7 @@ import type { PluginHookHandler, PluginHookName, PluginMentionTrigger, + PluginMachineProviderDeclaration, PluginProviderCapabilities, PluginProviderComposerAction, PluginProviderDeclaration, @@ -2042,6 +2043,14 @@ export function enforcePluginCliOutputLimit( result: Omit, jsonOutput: boolean, ): PluginCliExecutionResult { + if (result.experimental_continue !== undefined) { + z.object({ + argv: z.array(z.string().max(262144)).max(100), + delayMs: z.number().int().min(0).max(60000), + }) + .strict() + .parse(result.experimental_continue); + } const stdoutBytes = Buffer.byteLength(result.stdout, "utf8"); const stderrBytes = Buffer.byteLength(result.stderr, "utf8"); const totalBytes = stdoutBytes + stderrBytes; @@ -2420,3 +2429,236 @@ const environmentProviderPolicySchema = z .strict(); export const MACHINE_PROVIDER_REQUIREMENT_NAMES = ["gitRemote"] as const; + +export type NormalizedPluginMachineProviderRequirements = { + [K in (typeof MACHINE_PROVIDER_REQUIREMENT_NAMES)[number]]: boolean; +}; + +export interface NormalizedPluginMachineProvider { + id: string; + displayName: string; + icon: string | null; + requires: NormalizedPluginMachineProviderRequirements; + inputs: StandardSchemaV1 | null; + inputsJsonSchema: JsonValue | null; + availability: NonNullable< + PluginMachineProviderDeclaration["availability"] + > | null; + validate: NonNullable | null; + environmentRow: + | import("../machine-provider.js").PluginMachineProviderEnvironmentRow + | null; + policy: import("../machine-provider.js").PluginMachineProviderPolicy; + experimental_idleSuspendMs: NonNullable< + PluginMachineProviderDeclaration["experimental_idleSuspendMs"] + > | null; + experimental_details: NonNullable< + PluginMachineProviderDeclaration["experimental_details"] + > | null; + experimental_reconcileCleanup: PluginMachineProviderDeclaration["experimental_reconcileCleanup"]; + create: PluginMachineProviderDeclaration["create"]; + experimental_observe?: PluginMachineProviderDeclaration["experimental_observe"]; + experimental_policy?: PluginMachineProviderDeclaration["experimental_policy"]; + 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 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( + `machine provider "${id}" icon must be a glyph, declared icon, or plugin-relative path`, + ); + } + if (icon.length === 0) { + throw new Error(`machine provider "${id}" declares an empty icon`); + } + } + const requires = declaration.requires ?? {}; + if ( + typeof requires !== "object" || + requires === null || + Array.isArray(requires) + ) { + throw new Error( + `machine provider "${id}" declares a requires that is not an object`, + ); + } + const gitRemote = requires.gitRemote; + if (gitRemote !== undefined && typeof gitRemote !== "boolean") { + throw new Error( + `machine provider "${id}" declares a requires.gitRemote that is not a boolean`, + ); + } + const inputs = normalizeMachineProviderInputs(id, declaration); + if ( + typeof declaration.create !== "function" || + typeof declaration.experimental_reconcileCleanup !== "function" || + typeof declaration.remove !== "function" + ) { + throw new Error( + `machine provider "${id}" must declare create, experimental_reconcileCleanup and remove functions`, + ); + } + for (const name of ["experimental_observe", "experimental_policy"] as const) { + if ( + declaration[name] !== undefined && + typeof declaration[name] !== "function" + ) + throw new Error( + `machine provider "${id}" declares a ${name} that is not a function`, + ); + } + 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`, + ); + } + const environmentRow = + declaration.environmentRow === undefined + ? null + : z + .object({ + displayName: z.string().trim().min(1).max(80), + environmentProviderId: z + .string() + .regex(ENVIRONMENT_PROVIDER_ID_PATTERN), + }) + .strict() + .parse(declaration.environmentRow); + for (const key of [ + "experimental_idleSuspendMs", + "experimental_details", + ] as const) { + if ( + declaration[key] !== undefined && + typeof declaration[key] !== "function" + ) + throw new Error( + `machine provider "${id}" declares ${key} that is not a function`, + ); + } + if (!hasSuspend && declaration.experimental_idleSuspendMs !== undefined) + throw new Error( + `machine provider "${id}" must declare suspend and resume with experimental_idleSuspendMs`, + ); + const policy = machineProviderPolicySchema.parse(declaration.policy); + if (!hasSuspend && policy.idleSuspendMs !== null) { + throw new Error( + `machine provider "${id}" must set policy.idleSuspendMs to null without suspend and resume`, + ); + } + return { + id, + displayName, + icon, + requires: { gitRemote: gitRemote === true }, + inputs: inputs === null ? null : inputs.schema, + inputsJsonSchema: inputs === null ? null : inputs.jsonSchema, + availability: declaration.availability ?? null, + validate: declaration.validate ?? null, + environmentRow, + policy, + experimental_idleSuspendMs: declaration.experimental_idleSuspendMs ?? null, + experimental_details: declaration.experimental_details ?? null, + experimental_reconcileCleanup: declaration.experimental_reconcileCleanup, + create: declaration.create, + experimental_observe: declaration.experimental_observe, + experimental_policy: declaration.experimental_policy, + 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 }; +} + +const machineProviderPolicySchema = z + .object({ + idleSuspendMs: z.number().int().nonnegative().nullable(), + retire: z.discriminatedUnion("after", [ + z + .object({ + after: z.literal("last-thread"), + graceMs: z.number().int().nonnegative(), + }) + .strict(), + z.object({ after: z.literal("never") }).strict(), + ]), + removeRetryMs: z.number().int().positive(), + }) + .strict(); diff --git a/packages/plugin-sdk/src/internal/plugin-app-collector.ts b/packages/plugin-sdk/src/internal/plugin-app-collector.ts index 99b1886426..0c0c24854e 100644 --- a/packages/plugin-sdk/src/internal/plugin-app-collector.ts +++ b/packages/plugin-sdk/src/internal/plugin-app-collector.ts @@ -11,6 +11,7 @@ import type { PluginContentScriptRegistration, PluginDiffRendererRegistration, PluginEnvironmentProviderInputsRegistration, + PluginMachineProviderInputsRegistration, PluginFileOpenerRegistration, PluginHomepageSectionRegistration, PluginCommandPaletteActionRegistration, @@ -305,6 +306,7 @@ export interface CollectedPluginAppRegistrations { providerIcons: PluginProviderIconRegistration[]; timelineRenderers: PluginTimelineRendererRegistration[]; environmentProviderInputs: PluginEnvironmentProviderInputsRegistration[]; + machineProviderInputs: PluginMachineProviderInputsRegistration[]; contentScripts: PluginContentScriptRegistration[]; } @@ -354,6 +356,7 @@ export function collectPluginAppRegistrations( providerIcons: [], timelineRenderers: [], environmentProviderInputs: [], + machineProviderInputs: [], contentScripts: [], }; sidebarFooterItemsByRegistrationSet.set(collected, sidebarFooterItems); @@ -379,6 +382,7 @@ export function collectPluginAppRegistrations( providerIcon: new Set(), timelineRenderer: new Set(), environmentProviderInputs: new Set(), + machineProviderInputs: new Set(), contentScript: new Set(), }; @@ -802,6 +806,18 @@ export function collectPluginAppRegistrations( component: requireComponent(kind, registration.component), }); }, + experimental_machineProviderInputs(registration) { + const kind = "slots.experimental_machineProviderInputs"; + const machineProviderId = requireProviderId( + kind, + registration?.machineProviderId, + ); + requireUniqueId(kind, seenIds.machineProviderInputs, machineProviderId); + collected.machineProviderInputs.push({ + machineProviderId, + component: requireComponent(kind, registration.component), + }); + }, }, experimental_sidebarFooter: new SidebarFooterCollector( collected.experimentalSidebarFooterItems, diff --git a/packages/plugin-sdk/src/machine-bootstrap.ts b/packages/plugin-sdk/src/machine-bootstrap.ts new file mode 100644 index 0000000000..6f214d5e4a --- /dev/null +++ b/packages/plugin-sdk/src/machine-bootstrap.ts @@ -0,0 +1,81 @@ +import type { + ServerAccessGrant, + ServerAccessSelection, +} from "./backend-contract.js"; +import type { PluginMachineProviderProgress } from "./machine-provider.js"; + +export interface EnrollmentBootstrap { + version: 2; + hostId: string; + serverUrl: string; + headers?: ServerAccessGrant["headers"]; + credential: string; + expiresAt: number; +} + +export type MachineEnrollment = + | { + id: string; + hostId: string; + state: "pending"; + bootstrap: EnrollmentBootstrap; + expiresAt: number; + } + | { id: string; hostId: string; state: "enrolled" }; + +export interface MachineExecutorRequest { + command: string[]; + timeoutMs: number; + signal: AbortSignal; + stdin?: string; +} + +export interface MachineExecutor { + exec( + request: MachineExecutorRequest, + ): Promise<{ exitCode: number; stdout: string; stderr: string }>; + writeFile?(path: string, contents: string, mode?: number): Promise; +} + +export interface MachineEnrollmentRequest { + key: string; + access?: ServerAccessSelection; +} + +export interface MachineConnectionRequest { + enrollmentId: string; + timeoutMs: number; + signal: AbortSignal; +} + +export interface MachineEnrollments { + prepare(request: MachineEnrollmentRequest): Promise; + waitForConnection( + request: MachineConnectionRequest, + ): Promise<{ hostId: string }>; + cancel(request: { enrollmentId: string }): Promise; +} + +export interface MachineBootstrapRequest extends MachineEnrollmentRequest { + executor: MachineExecutor; + daemon: { kind: "preinstalled" } | { kind: "install" }; + report: PluginMachineProviderProgress; + signal: AbortSignal; +} + +export interface MachineInstallerCommand { + command: string[]; + stdin: string; +} + +export interface MachineBootstrapApi { + enrollments: MachineEnrollments; + prepareEnrollment( + request: MachineEnrollmentRequest, + ): Promise; + waitForConnection( + request: MachineConnectionRequest, + ): Promise<{ hostId: string }>; + installerCommand(bootstrap: EnrollmentBootstrap): MachineInstallerCommand; + 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..b656eaf975 --- /dev/null +++ b/packages/plugin-sdk/src/machine-provider.ts @@ -0,0 +1,176 @@ +import type { Project } from "@bb/domain"; +import type { + JsonValue, + PluginMachineProviderRequirements, + PluginMachineValidateDecision, + StandardSchemaV1, + StandardSchemaV1InferOutput, +} from "@get-bb/plugin-sdk"; + +export type PluginMachineProviderInputsSchema = StandardSchemaV1 | undefined; +type InputsValue = S extends StandardSchemaV1 + ? StandardSchemaV1InferOutput + : null; +type ProjectFacts = + | { project: null; gitRemote: null } + | (R extends Record<"gitRemote", true> + ? { project: Project; gitRemote: string } + : { project: Project; gitRemote: string | null }); + +export interface PluginMachineProviderProgress { + step(text: string): void; + log(text: string): void; +} + +export interface PluginMachineProviderAvailabilityContext { + project: Project | null; + gitRemote: string | null; +} + +export type PluginMachineProviderAvailability = + | { status: "available" } + | { status: "setup-required"; message: string } + | { status: "unavailable"; message: string }; + +export type PluginMachineProviderValidateContext< + R extends PluginMachineProviderRequirements = + PluginMachineProviderRequirements, + S extends PluginMachineProviderInputsSchema = + PluginMachineProviderInputsSchema, +> = ProjectFacts & { + inputs: InputsValue; +}; + +export type PluginMachineProviderCreateContext< + R extends PluginMachineProviderRequirements = + PluginMachineProviderRequirements, + S extends PluginMachineProviderInputsSchema = + PluginMachineProviderInputsSchema, +> = PluginMachineProviderValidateContext & { + key: string; + attempt: number; + /** Await the allocation recovery record after preparing enrollment, before bootstrap. This is not a filesystem save. Never include a bootstrap bundle. Daemon connection does not imply agent readiness. */ + checkpoint(resource: JsonValue): Promise; + report: PluginMachineProviderProgress; + signal: AbortSignal; +}; + +export type PluginMachineProviderCreateResult = + | { status: "created"; hostId: string; resource: JsonValue } + | { + status: "failed"; + failure: "transient" | "terminal"; + message: string; + /** Definitive rejection before allocation. Omit when allocation may have occurred. */ + allocation?: "none"; + }; + +export interface PluginMachineProviderLifecycleContext { + hostId: string; + resource: JsonValue; + report: PluginMachineProviderProgress; + signal: AbortSignal; +} + +export interface PluginMachineProviderSuspendContext extends PluginMachineProviderLifecycleContext { + /** Persist before terminating compute; supply the time only after a successful filesystem save. */ + checkpoint(resource: JsonValue, experimental_snapshotAt?: number): void; +} + +export interface PluginMachineProviderResumeContext extends PluginMachineProviderLifecycleContext { + /** Await the allocation recovery record before bootstrap. Core fences ownership, phase and operation; restart reuses this record and enrollment. This does not save the filesystem or establish agent readiness. */ + checkpoint(resource: JsonValue): Promise; +} + +export interface PluginMachineProviderResourceResult { + resource: JsonValue; +} + +export type PluginMachineProviderRemoveResult = + | { status: "removed" } + | { status: "failed"; message: string }; + +export interface PluginMachineProviderEnvironmentRow { + displayName: string; + environmentProviderId: string; +} + +export interface PluginMachineProviderPolicy { + idleSuspendMs: number | null; + retire: { after: "last-thread"; graceMs: number } | { after: "never" }; + removeRetryMs: number; +} + +export interface PluginMachineProviderDefinition< + R extends PluginMachineProviderRequirements = + PluginMachineProviderRequirements, + S extends PluginMachineProviderInputsSchema = + PluginMachineProviderInputsSchema, +> { + id: string; + displayName: string; + /** Omit to present provider-created machines like ordinary enrolled machines. */ + icon?: string; + requires?: R; + /** Persisted and readable by every plugin. Store secret references, never secrets. */ + inputs?: S; + availability?( + context: PluginMachineProviderAvailabilityContext, + ): + | PluginMachineProviderAvailability + | Promise; + validate?( + context: PluginMachineProviderValidateContext, + ): PluginMachineValidateDecision | Promise; + environmentRow?: PluginMachineProviderEnvironmentRow; + policy: PluginMachineProviderPolicy; + /** Resolve a per-machine idle timeout; core retains activity checks and retirement policy. */ + experimental_idleSuspendMs?(context: { + hostId: string; + resource: JsonValue; + }): Promise; + /** Return provider-owned inventory and estimated costs for machine details. */ + experimental_details?(context: { + hostId: string; + resource: JsonValue; + signal: AbortSignal; + }): Promise<{ summary: string; values: JsonValue }>; + + create( + context: PluginMachineProviderCreateContext, + ): Promise; + /** Reconcile and remove an uncertain allocation by durable key without creating or bootstrapping. Return failed while allocation intent remains unresolved. */ + experimental_reconcileCleanup(context: { + key: string; + report: PluginMachineProviderProgress; + signal: AbortSignal; + }): Promise; + /** Read vendor state without allocation or identity changes. Deadlines use UTC milliseconds. */ + experimental_observe?(context: { + hostId: string; + resource: JsonValue; + signal: AbortSignal; + }): Promise<{ + state: "running" | "suspended" | "missing" | "unknown"; + expiresAt: number | null; + resource: JsonValue; + }>; + /** Evaluate current effective policy on every sweep; null disables the corresponding deadline. */ + experimental_policy?(context: { + hostId: string; + resource: JsonValue; + }): Promise<{ + idleSuspendMs: number | null; + retireAfterMs: number | null; + deadlineLeadMs: number | null; + }>; + suspend?( + context: PluginMachineProviderSuspendContext, + ): Promise; + resume?( + context: PluginMachineProviderResumeContext, + ): Promise; + remove( + context: PluginMachineProviderLifecycleContext, + ): Promise; +} diff --git a/packages/plugin-sdk/src/rpc-contract.ts b/packages/plugin-sdk/src/rpc-contract.ts index 46d2345ebb..3ffb794aad 100644 --- a/packages/plugin-sdk/src/rpc-contract.ts +++ b/packages/plugin-sdk/src/rpc-contract.ts @@ -12,12 +12,14 @@ export type PluginRpcErrorCode = | "invalid_json" | "invalid_input" | "handler_error" + | "conflict" | "invalid_output" | "non_json_result" | "unknown_method"; /** Structured RPC failure returned as `{ ok: false, error }`. */ export interface PluginRpcError { + latestRevision?: number | null; code: PluginRpcErrorCode; message: string; issues?: PluginRpcValidationIssue[]; @@ -98,3 +100,14 @@ export type PluginRpcCallArgs = export type PluginRpcResult = StandardSchemaV1InferOutput; + +/** A handler rejects a stale revision or conflicting idempotency key with HTTP 409. */ +export class experimental_PluginRpcConflict extends Error { + constructor( + message: string, + public readonly latestRevision: number | null, + ) { + super(message); + this.name = "experimental_PluginRpcConflict"; + } +} 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 07e182cdf7..4fa8090f11 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 @@ -2051,6 +2051,70 @@ describe("environment targets", () => { }); }); + it("accepts a machine provider without suspend and resume when idle suspension is disabled", () => { + const { bb, harness } = createFakePluginHost(); + bb.experimental_machines.register({ + id: "test-machine", + displayName: "Test machine", + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 1_000, + }, + experimental_reconcileCleanup: async () => ({ status: "removed" }), + create: async () => ({ + status: "created", + hostId: "host-test-machine", + resource: { target: "staging" }, + }), + remove: async () => ({ status: "removed" }), + }); + expect( + harness.registrations.machineProviders.get("test-machine"), + ).toMatchObject({ + icon: null, + suspend: null, + resume: null, + policy: { idleSuspendMs: null }, + }); + }); + + it("requires machine suspend and resume as a pair and disables idle suspension without them", () => { + const create = async () => ({ + status: "created" as const, + hostId: "host-machine", + resource: null, + }); + const remove = async () => ({ status: "removed" as const }); + const lifecycle = async () => ({ resource: null }); + const policy = { + idleSuspendMs: null, + retire: { after: "never" as const }, + removeRetryMs: 1_000, + }; + expect(() => + createFakePluginHost().bb.experimental_machines.register({ + id: "half-lifecycle", + displayName: "Half lifecycle", + policy, + create, + experimental_reconcileCleanup: remove, + suspend: lifecycle, + remove, + }), + ).toThrow(/declare suspend and resume together/); + expect(() => + createFakePluginHost().bb.experimental_machines.register({ + id: "idle-without-lifecycle", + displayName: "Idle without lifecycle", + policy: { ...policy, idleSuspendMs: 1_000 }, + create, + experimental_reconcileCleanup: remove, + remove, + }), + ).toThrow(/idleSuspendMs to null without suspend and resume/); + }); + 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..9f33498b81 100644 --- a/packages/plugin-sdk/src/testing/app.tsx +++ b/packages/plugin-sdk/src/testing/app.tsx @@ -68,11 +68,12 @@ import { type ExperimentalOpenFixedTabOptions, type ExperimentalPluginFixedTabReference, type NewThreadComposerProps, - type BranchPickerProps, + type ExperimentalBranchPickerProps, type CheckoutState, type ExperimentalPermissionModePickerProps, type ExperimentalProviderModelPickerProps, type PluginEnvironmentProviderInputsRegistration, + type PluginMachineProviderInputsRegistration, type ThreadChatProps, type DiffProps, type SourceCodeProps, @@ -621,7 +622,7 @@ function TestBranchPicker({ label, placeholder, disabled, -}: BranchPickerProps) { +}: ExperimentalBranchPickerProps) { const inert = hostId === null || projectId === null || disabled === true; return (
; + 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 +487,7 @@ export interface FakePluginHarness } export interface CreateFakePluginHostOptions { + machineBootstrap?: MachineBootstrapApi; /** Defaults to "test-plugin". */ pluginId?: string; /** @@ -1801,6 +1811,11 @@ function createFakePluginHostInternal( 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; @@ -2129,6 +2144,37 @@ function createFakePluginHostInternal( }, }; + const unavailableMachineBootstrap = (): never => { + throw new Error( + "Configure machineBootstrap in createFakePluginHost to exercise machine enrollment", + ); + }; + const experimental_machines: PluginMachines = { + ...(options.machineBootstrap ?? { + enrollments: { + prepare: unavailableMachineBootstrap, + waitForConnection: unavailableMachineBootstrap, + cancel: unavailableMachineBootstrap, + }, + prepareEnrollment: unavailableMachineBootstrap, + waitForConnection: unavailableMachineBootstrap, + installerCommand: unavailableMachineBootstrap, + 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 +2191,13 @@ function createFakePluginHostInternal( events, experimental_hooks, experimental_environments, + experimental_machines, + experimental_serverAccess: { + register(declaration) { + assertLive(); + serverAccessProviders.set(declaration.id, declaration); + }, + }, status, server, hosts, @@ -2258,7 +2311,12 @@ function createFakePluginHostInternal( get environmentProviders() { return new Map(environmentProviders); }, - + get serverAccessProviders() { + return new Map(serverAccessProviders); + }, + get machineProviders() { + return new Map(machineProviders); + }, mentionProviders, providerRegistrations, providerEnvResolvers, @@ -2377,6 +2435,20 @@ function createFakePluginHostInternal( try { result = await record.handler(validatedInput as never); } catch (error) { + if ( + error instanceof Error && + error.name === "experimental_PluginRpcConflict" && + "latestRevision" in error && + (error.latestRevision === null || + (typeof error.latestRevision === "number" && + Number.isSafeInteger(error.latestRevision) && + error.latestRevision >= 0)) + ) + return throwRpcError({ + code: "conflict", + message: error.message, + latestRevision: error.latestRevision, + }); return throwRpcError({ code: "handler_error", message: errorMessage(error), @@ -2405,6 +2477,9 @@ function createFakePluginHostInternal( return enforcePluginCliOutputLimit( { exitCode: result.exitCode, + ...(result.experimental_continue + ? { experimental_continue: result.experimental_continue } + : {}), stdout: typeof result.stdout === "string" ? result.stdout : "", stderr: typeof result.stderr === "string" ? result.stderr : "", }, diff --git a/packages/plugin-sdk/src/testing/fixtures.ts b/packages/plugin-sdk/src/testing/fixtures.ts index 98881489f8..1f5ce6a1e2 100644 --- a/packages/plugin-sdk/src/testing/fixtures.ts +++ b/packages/plugin-sdk/src/testing/fixtures.ts @@ -213,7 +213,15 @@ export function makeMessageDispatchHookContext( id: "host-1", name: "Test host", status: "connected", - type: "persistent", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, diff --git a/packages/process-utils/src/index.ts b/packages/process-utils/src/index.ts index e9073e948c..abe2c4d09c 100644 --- a/packages/process-utils/src/index.ts +++ b/packages/process-utils/src/index.ts @@ -621,3 +621,5 @@ export function installSafeProcessDiagnostics( process.off("uncaughtExceptionMonitor", handleUncaughtExceptionMonitor); }; } + +export { createSecretStreamRedactor } from "./secret-redaction.js"; diff --git a/packages/process-utils/src/secret-redaction.test.ts b/packages/process-utils/src/secret-redaction.test.ts new file mode 100644 index 0000000000..11d01eb2d7 --- /dev/null +++ b/packages/process-utils/src/secret-redaction.test.ts @@ -0,0 +1,38 @@ +import { expect, it } from "vitest"; +import { createSecretStreamRedactor } from "./secret-redaction.js"; + +it.each(["first-line\nsecond-line", "first-line\r\nsecond-line"])( + "matches LF and CRLF variants of %j across every chunk boundary", + (secret) => { + for (const printed of [ + "first-line\nsecond-line", + "first-line\r\nsecond-line", + ]) { + const text = `before ${printed} after`; + for (let split = 0; split <= text.length; split += 1) { + const redactor = createSecretStreamRedactor([secret]); + expect( + redactor.push(text.slice(0, split)) + + redactor.push(text.slice(split)) + + redactor.flush(), + ).toBe("before [redacted] after"); + } + } + }, +); + +it("retains overlapping prefixes without rewriting replacement markers", () => { + const redactor = createSecretStreamRedactor(["abc", "abcdef", "redacted"]); + expect(redactor.push("value abc")).toBe("value "); + expect(redactor.push("def redacted!")).toBe("[redacted] [redacted]!"); + expect(redactor.flush()).toBe(""); +}); + +it("hides an unfinished prefix at cancellation and retains secrets during rotation", () => { + let secrets = ["old-token"]; + const redactor = createSecretStreamRedactor(() => secrets); + expect(redactor.push("old-")).toBe(""); + secrets = ["new-token"]; + expect(redactor.push("token new-")).toBe("[redacted] "); + expect(redactor.flush()).toBe("[redacted]"); +}); diff --git a/packages/process-utils/src/secret-redaction.ts b/packages/process-utils/src/secret-redaction.ts new file mode 100644 index 0000000000..10aab8ac24 --- /dev/null +++ b/packages/process-utils/src/secret-redaction.ts @@ -0,0 +1,53 @@ +export function createSecretStreamRedactor( + source: readonly string[] | (() => readonly string[]), +) { + let pending = ""; + const known = new Set(); + function secrets(): string[] { + for (const value of typeof source === "function" ? source() : source) { + if (!value) continue; + known.add(value); + known.add(value.replaceAll("\n", "\r\n")); + const lf = value.replaceAll("\r\n", "\n"); + known.add(lf); + known.add(lf.replaceAll("\n", "\r\n")); + } + return [...known].sort((a, b) => b.length - a.length); + } + return { + push(chunk: string): string { + const patterns = secrets(); + const text = pending + chunk; + pending = ""; + if (patterns.length === 0) return text; + let output = ""; + for (let index = 0; index < text.length;) { + const remaining = text.length - index; + if ( + patterns.some( + (secret) => + remaining < secret.length && secret.startsWith(text.slice(index)), + ) + ) { + pending = text.slice(index); + break; + } + const match = patterns.find((secret) => text.startsWith(secret, index)); + if (match) { + output += "[redacted]"; + index += match.length; + } else { + output += text[index]; + index += 1; + } + } + return output; + }, + flush(): string { + const output = pending ? "[redacted]" : ""; + pending = ""; + known.clear(); + return output; + }, + }; +} diff --git a/packages/provider-bridge-protocol/src/testing/first-party-replay.test.ts b/packages/provider-bridge-protocol/src/testing/first-party-replay.test.ts new file mode 100644 index 0000000000..611a471ee0 --- /dev/null +++ b/packages/provider-bridge-protocol/src/testing/first-party-replay.test.ts @@ -0,0 +1,25 @@ +import { expect, it } from "vitest"; +import { resolveReplayProfile } from "./first-party-replay.js"; + +it("preserves legacy Codex turn recordings that used an empty environment to keep session values", () => { + const rewrite = resolveReplayProfile("codex").rewriteRuntimeLine; + const message = { + jsonrpc: "2.0", + id: 2, + method: "turn/start", + params: { + threadId: "thr_recorded", + options: { envVars: {}, permissionMode: "full" }, + }, + }; + const rewritten = rewrite?.(JSON.stringify(message), { replayCommand: [] }); + expect(JSON.parse(rewritten ?? "null")).toEqual({ + ...message, + params: { ...message.params, options: { permissionMode: "full" } }, + }); + const changed = JSON.stringify({ + ...message, + params: { ...message.params, options: { envVars: { ROTATED: "new" } } }, + }); + expect(rewrite?.(changed, { replayCommand: [] })).toBe(changed); +}); diff --git a/packages/provider-bridge-protocol/src/testing/first-party-replay.ts b/packages/provider-bridge-protocol/src/testing/first-party-replay.ts index 50e1fcf150..e2e305ca96 100644 --- a/packages/provider-bridge-protocol/src/testing/first-party-replay.ts +++ b/packages/provider-bridge-protocol/src/testing/first-party-replay.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { z } from "zod"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { isAbsolute, join, resolve } from "node:path"; import { @@ -75,6 +76,7 @@ export function resolveReplayProfile( return { dialect: "json-rpc", bridgeFamily: "codex", + rewriteRuntimeLine: rewriteLegacyCodexTurnEnvironment, env: ({ replayCommand }) => ({ BB_CODEX_BRIDGE_APP_SERVER_COMMAND: replayCommand[0], BB_CODEX_BRIDGE_APP_SERVER_ARGS: JSON.stringify(replayCommand.slice(1)), @@ -116,6 +118,31 @@ export function resolveReplayProfile( throw new UnreplayableProviderError(providerId, "no replay profile"); } +const legacyCodexTurnSchema = z + .object({ + method: z.literal("turn/start"), + params: z + .object({ + options: z.object({ envVars: z.object({}).strict() }).passthrough(), + }) + .passthrough(), + }) + .passthrough(); + +function rewriteLegacyCodexTurnEnvironment(line: string): string { + try { + const parsed = legacyCodexTurnSchema.safeParse(JSON.parse(line)); + if (!parsed.success) return line; + const { envVars: _envVars, ...options } = parsed.data.params.options; + return JSON.stringify({ + ...parsed.data, + params: { ...parsed.data.params, options }, + }); + } catch { + return line; + } +} + function claudeConfigDir(stateDir: string): string { return join(stateDir, "claude-config"); } diff --git a/packages/scripts/src/lib/seed-perf-fixture.ts b/packages/scripts/src/lib/seed-perf-fixture.ts index 9ef3619226..e91c61f541 100644 --- a/packages/scripts/src/lib/seed-perf-fixture.ts +++ b/packages/scripts/src/lib/seed-perf-fixture.ts @@ -770,7 +770,6 @@ export function seedPerfFixture( .values({ id: options.hostId, name: "seed-host", - type: "persistent", maxPermissionMode: "full", lastSeenAt: now, createdAt: fixtureStart, 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..593d302b2f 100644 --- a/packages/sdk/src/areas/hosts.ts +++ b/packages/sdk/src/areas/hosts.ts @@ -1,11 +1,20 @@ +import type { + experimental_HostLifecycleRequest, + experimental_HostLifecycleResponse, + experimental_HostReadinessRequest, + experimental_HostReadinessResponse, +} from "@bb/server-contract"; import { hostProviderCliInstallEventSchema } from "@bb/server-contract"; -import type { Host } from "@bb/domain"; +import type { Host, JsonValue } from "@bb/domain"; import type { CreateHostJoinCodeResponse, + CreateMachineRequest, + MachineLaunchStatus, HostCloneDefaultPathQuery, HostCloneDefaultPathResponse, HostDirectoryListing, HostDirectoryQuery, + HostActionResponse, HostPathsExistRequest, HostPathsExistResponse, HostPickFolderRequest, @@ -15,6 +24,7 @@ import type { HostProviderCliStatusResponse, HostRetryUpdateResponse, UpdateHostRequest, + SystemMachineProvider, } from "@bb/server-contract"; import { signalRequestArgs, type CreateSdkAreaArgs } from "./common.js"; @@ -35,6 +45,10 @@ export interface HostRetryUpdateArgs { hostId: string; } +export interface HostActionArgs { + hostId: string; +} + export interface HostDirectoryArgs extends HostDirectoryQuery { hostId: string; signal?: AbortSignal; @@ -63,10 +77,19 @@ export interface HostListArgs { signal?: AbortSignal; } +export interface MachineCreateArgs extends CreateMachineRequest { + signal?: AbortSignal; +} + +export interface MachineProviderListArgs { + projectId?: string; + 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 HostCloneDefaultPathResult = HostCloneDefaultPathResponse; export type HostProviderCliInstallResult = HostProviderCliInstallEvent[]; export type HostListResult = Host[]; @@ -74,9 +97,37 @@ 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_providerDetails( + args: HostGetArgs, + ): Promise<{ summary: string; values: JsonValue } | null>; + experimental_lifecycle( + args: experimental_HostLifecycleRequest & { hostId: string }, + ): Promise; + experimental_ensureReady( + args: experimental_HostReadinessRequest & { hostId: string }, + ): Promise; + create(args: MachineCreateArgs): Promise; + submit(args: MachineCreateArgs): Promise; + launch(args: { + id: string; + signal?: AbortSignal; + }): Promise; + experimental_enrollmentCommand(args: { + id: string; + scope?: "launch" | "thread"; + signal?: AbortSignal; + }): Promise<{ command: string | null }>; + cancel(args: { id: string }): Promise; + follow(args: { + id: string; + signal?: AbortSignal; + onProgress?: (status: MachineLaunchStatus) => void; + }): Promise; createJoinCode(): Promise; delete(args: HostDeleteArgs): Promise; directory(args: HostDirectoryArgs): Promise; @@ -88,19 +139,105 @@ export interface HostsArea { args: HostProviderCliInstallArgs, ): Promise; list(args?: HostListArgs): Promise; + listProviders( + args?: MachineProviderListArgs, + ): Promise; pathsExist(args: HostPathsExistArgs): Promise; pickFolder(args: HostPickFolderArgs): Promise; providerCliStatus(args: HostGetArgs): Promise; + resume(args: HostActionArgs): Promise; + retryCleanup(args: HostActionArgs): Promise; retryUpdate(args: HostRetryUpdateArgs): Promise; + suspend(args: HostActionArgs): Promise; update(args: HostUpdateArgs): Promise; } export function createHostsArea(args: CreateSdkAreaArgs): HostsArea { const { transport } = args; return { + async experimental_providerDetails(input) { + return transport.readJson( + transport.api.v1.hosts[":id"]["provider-details"].$get( + { param: { id: input.hostId } }, + ...signalRequestArgs(input.signal), + ), + ); + }, + async experimental_lifecycle(input) { + return transport.readJson( + transport.api.v1.hosts[":id"].lifecycle.$post({ + param: { id: input.hostId }, + json: { keep: input.keep }, + }), + ); + }, + async experimental_ensureReady(input) { + return transport.readJson( + transport.api.v1.hosts[":id"].ready.$post({ + param: { id: input.hostId }, + json: { providerId: input.providerId, projectId: input.projectId }, + }), + ); + }, + async create(input) { + const launch = await this.submit(input); + return this.follow({ id: launch.id, signal: input.signal }); + }, + async launch(input) { + return transport.readJson( + transport.api.v1.hosts.launches[":id"].$get( + { param: { id: input.id } }, + ...signalRequestArgs(input.signal), + ), + ); + }, + async experimental_enrollmentCommand(input) { + return transport.readJson( + transport.api.v1.hosts.launches[":id"]["enrollment-command"].$get( + { param: { id: input.id }, query: { scope: input.scope } }, + ...signalRequestArgs(input.signal), + ), + ); + }, + async cancel(input) { + return transport.readJson( + transport.api.v1.hosts.launches[":id"].cancel.$post({ + param: { id: input.id }, + }), + ); + }, + async follow(input) { + for (;;) { + input.signal?.throwIfAborted(); + const status = await this.launch(input); + input.onProgress?.(status); + if (status.phase === "ready" && status.hostId !== null) + return this.get({ hostId: status.hostId, signal: input.signal }); + if (status.terminal) + throw new Error(status.message ?? "Machine creation cancelled"); + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + }, + async submit(input) { + return transport.readJson( + transport.api.v1.hosts.$post( + { + json: { + machineProviderId: input.machineProviderId, + projectId: input.projectId, + inputs: input.inputs, + ...(input.key === undefined ? {} : { key: input.key }), + }, + }, + ...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 +290,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) @@ -166,6 +303,20 @@ export function createHostsArea(args: CreateSdkAreaArgs): HostsArea { transport.api.v1.hosts.$get({}, ...signalRequestArgs(input?.signal)), ); }, + async listProviders(input) { + const response = await transport.readJson( + transport.api.v1.system["machine-providers"].$get( + { + query: + input?.projectId === undefined + ? {} + : { projectId: input.projectId }, + }, + ...signalRequestArgs(input?.signal), + ), + ); + return response.providers; + }, async pathsExist(input) { return transport.readJson( transport.api.v1.hosts[":id"].paths.exist.$post( @@ -198,6 +349,20 @@ export function createHostsArea(args: CreateSdkAreaArgs): HostsArea { ), ); }, + async resume(input) { + return transport.readJson( + transport.api.v1.hosts[":id"].resume.$post({ + param: { id: input.hostId }, + }), + ); + }, + async 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 +370,13 @@ export function createHostsArea(args: CreateSdkAreaArgs): HostsArea { }), ); }, + async 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 1b5a5d3ca6..b4ddaaa3c9 100644 --- a/packages/sdk/src/areas/system.ts +++ b/packages/sdk/src/areas/system.ts @@ -1,3 +1,7 @@ +import type { + MachineEnvironmentSet, + MachineEnvironmentList, +} from "@bb/server-contract"; import type { AppKeybindingOverrides, AppSettings, @@ -76,6 +80,11 @@ export type SystemProviderStatesResult = SystemProviderStatesResponse; export type SystemVersionResult = SystemVersionResponse; export interface SystemArea { + machineEnvironment(): Promise; + setMachineEnvironment( + input: MachineEnvironmentSet, + ): Promise; + unsetMachineEnvironment(name: string): Promise; attention(args?: SystemAttentionArgs): Promise; config(args?: SystemConfigArgs): Promise; executionOptions( @@ -114,6 +123,23 @@ function versionQuery(args: SystemVersionArgs | undefined): SystemVersionQuery { export function createSystemArea(args: CreateSdkAreaArgs): SystemArea { const { transport } = args; return { + async machineEnvironment() { + return transport.readJson( + transport.api.v1.settings["machine-environment"].$get(), + ); + }, + async setMachineEnvironment(input) { + return transport.readJson( + transport.api.v1.settings["machine-environment"].$put({ json: input }), + ); + }, + async unsetMachineEnvironment(name) { + return transport.readJson( + transport.api.v1.settings["machine-environment"][":name"].$delete({ + param: { name }, + }), + ); + }, 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 59e8ce9b9a..a0ad3d5503 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/machine-enrollment-command.test.ts b/packages/sdk/test/machine-enrollment-command.test.ts new file mode 100644 index 0000000000..abb20fe253 --- /dev/null +++ b/packages/sdk/test/machine-enrollment-command.test.ts @@ -0,0 +1,37 @@ +import { expect, it } from "vitest"; +import { createBbSdk } from "../src/core.js"; +import { createHttpTransport } from "../src/transport-http.js"; + +it("distinguishes thread command resolution from an exact consumed launch", async () => { + const urls: URL[] = []; + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + runtime: "node", + fetch: async (url) => { + const requestUrl = new URL(String(url)); + urls.push(requestUrl); + return Response.json({ + command: + requestUrl.searchParams.get("scope") === "thread" + ? "replacement-command" + : null, + }); + }, + }), + }); + expect( + await sdk.hosts.experimental_enrollmentCommand({ id: "thread" }), + ).toEqual({ command: null }); + expect( + await sdk.hosts.experimental_enrollmentCommand({ + id: "thread", + scope: "thread", + }), + ).toEqual({ command: "replacement-command" }); + expect(urls.map((url) => url.pathname)).toEqual([ + "/api/v1/hosts/launches/thread/enrollment-command", + "/api/v1/hosts/launches/thread/enrollment-command", + ]); + expect(urls.map((url) => url.search)).toEqual(["", "?scope=thread"]); +}); diff --git a/packages/sdk/test/public-types.test.ts b/packages/sdk/test/public-types.test.ts index 3456f9b744..464db89c5e 100644 --- a/packages/sdk/test/public-types.test.ts +++ b/packages/sdk/test/public-types.test.ts @@ -273,17 +273,30 @@ type ExpectedFilesKey = type ExpectedGuideKey = "render"; type ExpectedHostsKey = + | "experimental_enrollmentCommand" + | "experimental_lifecycle" + | "experimental_ensureReady" + | "submit" + | "launch" + | "follow" + | "cancel" + | "experimental_providerDetails" | "cloneDefaultPath" + | "create" | "createJoinCode" | "delete" | "directory" | "get" | "installProviderCli" | "list" + | "listProviders" | "pathsExist" | "pickFolder" | "providerCliStatus" + | "resume" + | "retryCleanup" | "retryUpdate" + | "suspend" | "update"; type ExpectedPluginsKey = @@ -334,6 +347,9 @@ type ExpectedProvidersKey = "list" | "models"; type ExpectedStatusKey = "get"; type ExpectedSystemKey = + | "machineEnvironment" + | "setMachineEnvironment" + | "unsetMachineEnvironment" | "attention" | "cliSkillsStatus" | "config" diff --git a/packages/sdk/test/sdk.test.ts b/packages/sdk/test/sdk.test.ts index b0243611a9..869a20582f 100644 --- a/packages/sdk/test/sdk.test.ts +++ b/packages/sdk/test/sdk.test.ts @@ -106,6 +106,141 @@ 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", + status: "connected", + machineProviderId: "digitalocean", + machineProviderSelection: { inputs: {} }, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, + maxPermissionMode: "full", + lastSeenAt: 1, + lastRejectedProtocolVersion: null, + createdAt: 1, + updatedAt: 1, + }; + const launch = { + id: "launch_do", + phase: "ready", + hostId: host.id, + step: "Ready", + log: "", + message: null, + cancelPending: false, + terminal: true, + }; + const queue = createFetchQueue([ + { body: { ...launch, phase: "creating", hostId: null, terminal: false } }, + { body: launch }, + { body: host }, + ]); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: queue.fetch, + runtime: "node", + }), + }); + expect( + await sdk.hosts.create({ + machineProviderId: "digitalocean", + projectId: null, + inputs: {}, + }), + ).toEqual(host); + expect(queue.requests).toEqual([ + { + bodyText: JSON.stringify({ + machineProviderId: "digitalocean", + projectId: null, + inputs: {}, + }), + method: "POST", + url: "http://bb.test/api/v1/hosts", + }, + { + bodyText: undefined, + method: "GET", + url: "http://bb.test/api/v1/hosts/launches/launch_do", + }, + { + bodyText: undefined, + method: "GET", + url: "http://bb.test/api/v1/hosts/host_do", + }, + ]); + }); + + it("requests a machine join code without a host type", async () => { + const queue = createFetchQueue([ + { body: { joinCode: "one", hostId: "host_1", expiresAt: 1 } }, + ]); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: queue.fetch, + runtime: "node", + }), + }); + + await sdk.hosts.createJoinCode(); + + expect(queue.requests).toEqual([ + { + bodyText: JSON.stringify({}), + method: "POST", + url: "http://bb.test/api/v1/hosts/join-codes", + }, + ]); + }); + + it("reads provider installation events through the response instance", async () => { + const events = [ + { + type: "started", + provider: "codex", + command: "npm install --global @openai/codex", + }, + { + type: "completed", + provider: "codex", + exitCode: 0, + signal: null, + success: true, + }, + ]; + const response = new Response(null, { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }); + Object.defineProperty(response, "text", { + value: async () => + events.map((event) => JSON.stringify(event)).join("\n"), + }); + const sdk = createBbSdk({ + transport: createHttpTransport({ + baseUrl: "http://bb.test", + fetch: async () => response, + runtime: "node", + }), + }); + + await expect( + sdk.hosts.installProviderCli({ + hostId: "host_test", + provider: "codex", + actionKind: "install", + }), + ).resolves.toEqual(events); + }); + it("sends thread pane presentation actions through the typed transport", async () => { const queue = createFetchQueue([{ body: { delivered: 3 } }]); const sdk = createBbSdk({ diff --git a/packages/server-contract/src/api/hosts.ts b/packages/server-contract/src/api/hosts.ts index cfcb8f3a5c..99e165c404 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,27 @@ export type CreateHostJoinCodeRequest = z.infer< typeof createHostJoinCodeRequestSchema >; +export const createMachineRequestSchema = z + .object({ + machineProviderId: z.string().min(1), + projectId: z.string().min(1).nullable(), + inputs: jsonValueSchema.nullable(), + key: z.string().min(1).optional(), + }) + .strict(); +export type CreateMachineRequest = z.infer; + +export interface MachineLaunchStatus { + id: string; + phase: "creating" | "ready" | "failed" | "cancelled"; + hostId: string | null; + step: string; + log: string; + message: string | null; + cancelPending: boolean; + terminal: boolean; +} + export const createHostJoinCodeResponseSchema = z.object({ joinCode: z.string().min(1), hostId: z.string().min(1), @@ -74,9 +95,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 +127,78 @@ export type HostProviderCliInstallRequest = ProviderCliInstallRequest; export const hostProviderCliInstallEventSchema = providerCliInstallEventSchema; export type HostProviderCliInstallEvent = ProviderCliInstallEvent; + +export const machineEnrollmentCommandQuerySchema = z.object({ + scope: z.enum(["launch", "thread"]).default("launch"), +}); +export type MachineEnrollmentCommandQuery = z.input< + typeof machineEnrollmentCommandQuerySchema +>; +export const experimental_hostReadinessRequestSchema = z + .object({ + providerId: z.string().min(1), + projectId: z.string().min(1), + }) + .strict(); +export type experimental_HostReadinessRequest = z.infer< + typeof experimental_hostReadinessRequestSchema +>; +export const experimental_hostReadinessResponseSchema = z.discriminatedUnion( + "status", + [ + z + .object({ + status: z.literal("ready"), + checks: z.array( + z + .object({ + kind: z.enum(["cli", "auth", "workspace"]), + status: z.literal("passed"), + }) + .strict(), + ), + }) + .strict(), + z + .object({ + status: z.literal("blocked"), + code: z.string(), + stage: z.enum(["cli", "auth", "workspace"]), + message: z.string(), + retryable: z.boolean(), + }) + .strict(), + ], +); +export type experimental_HostReadinessResponse = z.infer< + typeof experimental_hostReadinessResponseSchema +>; + +export const experimental_hostLifecycleRequestSchema = z + .object({ keep: z.boolean().optional() }) + .strict(); +export type experimental_HostLifecycleRequest = z.infer< + typeof experimental_hostLifecycleRequestSchema +>; +export const experimental_hostLifecycleResponseSchema = z + .object({ + phase: z.string(), + expiresAt: z.number().nullable(), + maintenanceAt: z.number().nullable(), + lastSnapshotAt: z.number().nullable(), + recoveryState: z.enum([ + "healthy", + "draining", + "saving", + "saved", + "recoverable", + "lost-since-last-snapshot", + ]), + message: z.string().nullable(), + retentionAt: z.number().nullable(), + keep: z.boolean(), + }) + .strict(); +export type experimental_HostLifecycleResponse = z.infer< + typeof experimental_hostLifecycleResponseSchema +>; 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..44ed076c8e --- /dev/null +++ b/packages/server-contract/src/api/machine-environment.ts @@ -0,0 +1,42 @@ +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", + ), + secret: z.boolean().default(false), + note: z.string().max(1024).nullable().default(null), + }) + .strict(); +export type MachineEnvironmentSet = z.infer; +export const machineEnvironmentVariableSchema = z + .object({ + name: machineEnvironmentNameSchema, + value: z.string().nullable(), + secret: z.boolean(), + 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"]), + 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..4c14cb2139 100644 --- a/packages/server-contract/src/api/shared.ts +++ b/packages/server-contract/src/api/shared.ts @@ -111,7 +111,14 @@ 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), + }), + ]), 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 023a607bc1..add1347040 100644 --- a/packages/server-contract/src/api/system.ts +++ b/packages/server-contract/src/api/system.ts @@ -123,7 +123,31 @@ export const systemAiServicesSchema = z.object({ }); export type SystemAiServices = z.infer; +export const serverAccessStatusSchema = z.object({ + providers: z.array( + z.object({ + attention: z.string().nullable(), + id: z.string(), + displayName: z.string(), + availability: z.discriminatedUnion("status", [ + z.object({ status: z.literal("available") }), + z.object({ status: z.literal("setup-required"), message: z.string() }), + z.object({ status: z.literal("unavailable"), message: z.string() }), + ]), + }), + ), + defaultProviderId: z.string().nullable(), + effectiveUrl: z.string().nullable(), + urlSource: z.enum(["setting", "BB_EXTERNAL_URL"]).nullable(), +}); +export type ServerAccessStatus = z.infer; + export const systemConfigResponseSchema = z.object({ + serverAccess: serverAccessStatusSchema, + machineGit: z.object({ + status: z.enum(["ready", "not configured"]), + statusMessage: z.string(), + }), generalSettings: appSettingsSchema.extend({ showUnhandledProviderEvents: z.boolean().optional(), }), @@ -301,3 +325,60 @@ 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), + icon: z.string().min(1).nullable(), + logoUrl: z.string().min(1).nullable(), + pluginId: z.string().min(1), + requires: z.object({ gitRemote: z.boolean() }), + inputs: jsonValueSchema.nullable(), + acceptsEmptyInputs: z.boolean(), + supportsSuspend: z.boolean(), + environmentRow: z + .object({ + displayName: z.string().min(1), + environmentProviderId: z.string().min(1), + }) + .nullable(), + policy: z.object({ + idleSuspendMs: z.number().int().nonnegative().nullable(), + retire: z.discriminatedUnion("after", [ + z.object({ + after: z.literal("last-thread"), + graceMs: z.number().int().nonnegative(), + }), + z.object({ after: z.literal("never") }), + ]), + removeRetryMs: z.number().int().positive(), + }), + availability: z + .discriminatedUnion("status", [ + z.object({ status: z.literal("available") }), + z.object({ + status: z.literal("setup-required"), + message: z.string().min(1), + }), + z.object({ + status: z.literal("unavailable"), + message: z.string().min(1), + }), + ]) + .nullable(), +}); +export type SystemMachineProvider = z.infer; + +export const systemMachineProvidersResponseSchema = z.object({ + providers: z.array(systemMachineProviderSchema), +}); +export type SystemMachineProvidersResponse = z.infer< + typeof systemMachineProvidersResponseSchema +>; + +export const systemMachineProvidersQuerySchema = z.object({ + projectId: z.string().min(1).optional(), +}); +export type SystemMachineProvidersQuery = z.infer< + typeof systemMachineProvidersQuerySchema +>; 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 7bbfb2d0e9..7e4d8728a9 100644 --- a/packages/server-contract/src/public-api.ts +++ b/packages/server-contract/src/public-api.ts @@ -1,3 +1,17 @@ +import type { JsonValue } from "@bb/domain"; +import { + machineEnvironmentSetSchema, + type MachineEnvironmentSet, + type MachineEnvironmentList, +} from "./api/machine-environment.js"; +import { + experimental_hostLifecycleRequestSchema, + type experimental_HostLifecycleRequest, + type experimental_HostLifecycleResponse, + experimental_hostReadinessRequestSchema, + type experimental_HostReadinessRequest, + type experimental_HostReadinessResponse, +} from "./api/hosts.js"; import { desktopBrowserHostRequestSchema, desktopBrowserScopeSchema, @@ -69,6 +83,7 @@ import type { CopyProjectAttachmentsRequest, CreateHostJoinCodeRequest, CreateHostJoinCodeResponse, + CreateMachineRequest, CreateTerminalRequest, CreateProjectRequest, CreateProjectSourceRequest, @@ -100,6 +115,8 @@ import type { EnvironmentStatusResponse, HostDirectoryListing, HostDirectoryQuery, + MachineEnrollmentCommandQuery, + HostActionResponse, HostCloneDefaultPathQuery, HostCloneDefaultPathResponse, HostFileListRequest, @@ -174,6 +191,8 @@ import type { SystemExecutionOptionsResponse, SystemEnvironmentProvidersQuery, SystemEnvironmentProvidersResponse, + SystemMachineProvidersQuery, + SystemMachineProvidersResponse, SystemProviderInfo, SystemProvidersQuery, SystemProviderStatesResponse, @@ -255,6 +274,8 @@ import { restartTerminalRequestSchema, createProjectRequestSchema, createHostJoinCodeRequestSchema, + createMachineRequestSchema, + type MachineLaunchStatus, createProjectSourceRequestSchema, createQueuedMessageRequestSchema, queuedMessageListQuerySchema, @@ -270,6 +291,7 @@ import { environmentPathsQuerySchema, environmentStatusQuerySchema, hostDirectoryQuerySchema, + machineEnrollmentCommandQuerySchema, hostCloneDefaultPathQuerySchema, hostFileListRequestSchema, hostFileReadRequestSchema, @@ -308,6 +330,7 @@ import { sendQueuedMessageRequestSchema, systemExecutionOptionsQuerySchema, systemEnvironmentProvidersQuerySchema, + systemMachineProvidersQuerySchema, systemProvidersQuerySchema, systemUsageLimitsQuerySchema, systemVersionQuerySchema, @@ -705,6 +728,34 @@ export const publicApiRoutes = { }, hosts: { + create: defineRoute({ + path: "/hosts", + method: "post", + request: jsonRequest( + createMachineRequestSchema, + ), + response: jsonResponse({ status: 201 }), + }), + launch: defineRoute({ + path: "/hosts/launches/:id", + method: "get", + request: noRequest(), + response: jsonResponse(), + }), + experimental_enrollmentCommand: defineRoute({ + path: "/hosts/launches/:id/enrollment-command", + method: "get", + request: optionalQueryRequest( + machineEnrollmentCommandQuerySchema, + ), + response: jsonResponse<{ command: string | null }>(), + }), + cancelLaunch: defineRoute({ + path: "/hosts/launches/:id/cancel", + method: "post", + request: noRequest(), + response: jsonResponse(), + }), createJoinCode: defineRoute({ path: "/hosts/join-codes", method: "post", @@ -723,7 +774,7 @@ export const publicApiRoutes = { path: "/hosts/:id", method: "get", request: noRequest(), - response: jsonResponse(), + response: jsonResponse(), }), update: defineRoute({ path: "/hosts/:id", @@ -745,6 +796,30 @@ export const publicApiRoutes = { request: noRequest(), response: jsonResponse(), }), + experimental_providerDetails: defineRoute({ + path: "/hosts/:id/provider-details", + method: "get", + request: noRequest(), + response: jsonResponse<{ summary: string; values: JsonValue } | null>(), + }), + suspend: defineRoute({ + path: "/hosts/:id/suspend", + method: "post", + request: noRequest(), + response: jsonResponse(), + }), + resume: defineRoute({ + path: "/hosts/:id/resume", + method: "post", + request: noRequest(), + response: jsonResponse(), + }), + retryCleanup: defineRoute({ + path: "/hosts/:id/retry-cleanup", + method: "post", + request: noRequest(), + response: jsonResponse(), + }), delete: defineRoute({ path: "/hosts/:id", method: "delete", @@ -783,6 +858,22 @@ export const publicApiRoutes = { ), response: jsonResponse(), }), + experimental_lifecycle: defineRoute({ + path: "/hosts/:id/lifecycle", + method: "post", + request: jsonRequest( + experimental_hostLifecycleRequestSchema, + ), + response: jsonResponse(), + }), + experimental_ensureReady: defineRoute({ + path: "/hosts/:id/ready", + method: "post", + request: jsonRequest( + experimental_hostReadinessRequestSchema, + ), + response: jsonResponse(), + }), providerCliStatus: defineRoute({ path: "/hosts/:id/provider-clis/status", method: "get", @@ -1493,6 +1584,26 @@ export const publicApiRoutes = { }, system: { + machineEnvironment: defineRoute({ + path: "/settings/machine-environment", + method: "get", + request: noRequest(), + response: jsonResponse(), + }), + setMachineEnvironment: defineRoute({ + path: "/settings/machine-environment", + method: "put", + request: jsonRequest( + machineEnvironmentSetSchema, + ), + response: jsonResponse(), + }), + unsetMachineEnvironment: defineRoute({ + path: "/settings/machine-environment/:name", + method: "delete", + request: noRequest<{ param: { name: string } }>(), + response: jsonResponse(), + }), attention: defineRoute({ path: "/system/attention", method: "get", @@ -1594,6 +1705,14 @@ export const publicApiRoutes = { >(systemEnvironmentProvidersQuerySchema), response: jsonResponse(), }), + machineProviders: defineRoute({ + path: "/system/machine-providers", + method: "get", + request: optionalQueryRequest( + systemMachineProvidersQuerySchema, + ), + 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 503bd78523..9b8028d219 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, @@ -580,6 +581,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( @@ -1905,6 +1916,54 @@ 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", diff --git a/packages/templates/src/templates/bb-guide-machines.md b/packages/templates/src/templates/bb-guide-machines.md index 3fee7e5c33..581795509c 100644 --- a/packages/templates/src/templates/bb-guide-machines.md +++ b/packages/templates/src/templates/bb-guide-machines.md @@ -7,12 +7,16 @@ editingNotes: Keep the user-facing noun machine; internal APIs and types use Hos --- 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 (Existing machine). 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 @@ -33,13 +37,30 @@ unless you pass `--auto-update` explicitly. bb machine list List machines with ID, connection status, and relative last-seen time --json Print the raw host list + bb machine ready --provider --project --json + Check CLI, auth and checkout readiness + bb machine providers [--project ] 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 start --host-id Start an owned local daemon + bb machine stop --host-id Stop an owned local daemon + bb machine uninstall --host-id Remove an owned local installation 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 + bb machine provider-cli install --action Each machine has a permission limit: the highest permission mode any thread on @@ -52,6 +73,23 @@ 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. Without `--project`, +creation is global; project selectors accept an exact name or ID. Omitted inputs +are null; supply JSON when the provider schema requires it. Omit `--key` to let +the server generate one, or supply a stable key for retries. Creation is durable: +`--no-wait` returns the launch ID immediately; otherwise the CLI follows progress through server retries until ready or a terminal failure. Launch status includes `terminal` to distinguish retryable failures. +SIGINT stops following and exits 130 while creation continues. Use +`bb machine status ` to poll and `bb machine cancel ` +to explicitly cancel and clean up, including retrying cleanup after automatic +reconciliation has stopped. The SDK provides `hosts.submit`, `hosts.launch`, +`hosts.follow`, and `hosts.cancel`; `hosts.create` submits and follows. 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 @@ -74,15 +112,164 @@ 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 thread spawn --project --new-machine --prompt "..." + --machine-inputs bb project create --name "..." --root --machine bb project source add --machine --path For thread spawning, machine targeting works with an unmanaged workspace path, a new managed worktree, or the personal workspace. Do not combine it with an existing environment ID: the reused environment already selects its machine. +`--new-machine` creates through a machine provider and uses its advertised +environment row when declared. Otherwise add `--environment-provider ` +(required for SSH). Use `--environment-inputs ` for workspace configuration, +separately from `--machine-inputs `. Machine inputs are persisted and +readable by plugins; never put secrets there. Store credentials in plugin settings and pass only +non-secret configuration or references. + +When `--new-machine` selects an environment provider requiring a project +checkout, core clones the project's Git remote and registers a source on the +connected machine before creating that environment. Existing sources are reused. +Automatic setup uses a stable per-project target and shares concurrent setup on +the same host. After a server restart, it registers a completed checkout whose +remote matches instead of cloning again; a conflicting target is refused. +The project needs a Git remote and the machine needs Git access to it. Choosing +Personal workspace first does not clone a project. Standalone `bb machine create` +does not set up a project source. For project creation and sources, `--root`/`--path` refers to a path on the selected connected machine. Omit the selector to keep the existing local CLI machine fallback (normally the primary machine). Pass `--clone` to source add instead of `--path` to clone the project's Git remote there; `--remote-url` and `--target-path` optionally override the clone inputs. + +## Server access + +Set General → Server URL reachable by machines, or run `bb settings general +machineServerUrl https://bb.example.com`. An unset value uses BB_EXTERNAL_URL. +General shows the effective URL and source. Set Default machine access with +`bb settings general defaultMachineAccess direct` or `connect`; `null` uses +paired Connect first, then direct when a URL exists. `bb settings show --json` +includes provider availability and the effective selection. Machines use this +access for ongoing runtime requests, including account-pool endpoints. + +The Tailscale plugin can supply private machine access without a Direct URL. +Use `bb tailscale devices`, `bb tailscale status`, and `bb tailscale configure +` to discover devices and validate a dedicated existing HTTPS Serve +mapping. Choose Tailscale explicitly; it is not selected by Automatic. +The plugin skill documents SSH prerequisites and safe endpoint cleanup. + +## Local daemon lifecycle + +`bb machine 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 updates server state. + +## 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 installer accepts `--bootstrap-env ` and uses the same enrollment command. It installs a private CLI and supplies `~/.local/bin/bb` without replacing an existing path. Non-login transports can use `command -v bb` with `~/.local/bin/bb` as a fallback. Linux machines without a systemd user session run a detached daemon; systemd and launchd machines receive a persistent service. + +Machine bootstrap v2 supplies optional server request headers. `bb machine enroll` +persists them privately as `serverHeaders`; the launcher passes `BB_SERVER_HEADERS` +to the daemon for enrollment, connection and runtime requests. Server-access +plugins redeem provider codes on the server. Pending encrypted v1 bundles are +upgraded by the server when prepared again. + + +Delivered enrollment bundles from v1 remain valid until their expiry. The CLI accepts both file and environment forms, upgrades the bundle to v2 headers locally, and persists legacy Connect redemption before enrollment so a retry reuses it. The installer upgrades v1 environment bundles before authenticated artifact downloads. +## DigitalOcean dev boxes + +`bb digitalocean configure ''` sets `idleMinutes` (null +turns idle stop off), `retention` (default 2), and `schedule` (null disables; +otherwise `weekdays` 0–6, `sleep`/`wake` HH:mm, and explicit IANA `timezone`). +`bb digitalocean snapshot-now ` drains through core, gracefully shuts +down, confirms off, snapshots and remains off. `sleep` does the same; `wake` +resumes through core. Busy threads and open terminals prevent sleep. Core also +wakes on dispatch. Empty boxes participate in opt-in idle stop; retirement stays +never. `status` and `cost` show live inventory and estimates; all accept `--json`. +`bb machine show --json` includes provider inventory in `providerDetails`. + +Powered-off droplets still bill; snapshot storage bills per GB. See +https://docs.digitalocean.com/products/droplets/details/pricing/ and +https://docs.digitalocean.com/products/snapshots/details/pricing/ . Configure a +weekday schedule from the plugin settings or CLI on an always-on BB server. +The latest missed action within eight days runs after recovery; busy sleep +retries each minute until superseded. See the plugin skill for DST and cleanup. + +Resume waits for any in-progress suspension before waking; an already-active +machine is left active. DigitalOcean sleep JSON retains saved power/backup +status if inventory is unavailable (`details.values.cost: null` and +`inventoryError`). Shared inventory reads cache for 30 seconds and invalidate +on mutations. Schedule changes invalidate selected, undispatched runs. + +Create DigitalOcean dev boxes from Settings → Machines or +`bb machine create --provider digitalocean --inputs '{}' --json`, without a +project. SDK creation uses `machineProviderId: "digitalocean", projectId: null, +inputs: {}`. Enrolled boxes appear as machine sections in the composer picker; +DigitalOcean contributes no new-machine/project-checkout shortcut row. + +Existing machines + +`bb machine create --provider manual` prints a private enrollment command and +follows the launch 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 +launch ID and command once enrollment is prepared; `--json` includes the command +in `command`. This command is fetched transiently from the encrypted pending +bundle; durable progress contains no credential. After enrollment or cancellation, +the command endpoint returns nothing. Treat this short-lived command as a credential. + +Use `bb machine status ` to recover progress and +`bb machine cancel ` 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 `bb machine 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 [--secret] [--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. GH_TOKEN is always secret. +Secret values use private files and are never returned by list or set. + +Settings → General → Machine environment has the same controls. 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. + +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(), system.setMachineEnvironment({ +name, value, secret, note }), and system.unsetMachineEnvironment(name). + +Provider-managed machine turns check readiness before dispatch. `bb machine ready` +checks the same CLI installation, credential routing reachability, and project +workspace setup. It returns ready checks or a blocked stage/code/message. +Compatible CLIs are reused. Core owns repository setup and teardown for +environments whose provider returns ownsPath: true. Readiness checks the recorded +setup outcome against the checkout inputs; it never runs a separate recipe script. +The repo hook owns dependency caching and its unchanged-input no-op path. + +`bb machine lifecycle MACHINE --json` shows the vendor expiry, planned maintenance, +last successful snapshot, recovery state and automatic retention removal deadline. +Use `--keep` to prevent automatic retention removal, or `--no-keep` to restore it. +Maintenance interrupts active turns and closes terminals before saving. Submit a +new continuation turn after restore; interrupted turns are never reported successful. + +`bb machine lifecycle MACHINE --remove --yes --json` removes retained compute and snapshots through the normal machine removal path. `--keep` prevents automatic retention deletion; `--no-keep` restores it. After filesystem restore, core reruns the owned checkout’s idempotent setup hook to restart services; hook failure blocks readiness. diff --git a/packages/templates/src/templates/bb-guide-plugins.md b/packages/templates/src/templates/bb-guide-plugins.md index f7b97a7ae3..43c9c9f735 100644 --- a/packages/templates/src/templates/bb-guide-plugins.md +++ b/packages/templates/src/templates/bb-guide-plugins.md @@ -900,3 +900,15 @@ 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 image setup uses `bb modal project inspect`, `recipe put/show/list`, +`context upload`, `image build/logs/status/cancel/list/gc`, and +`project configure/show`. Every command supports `--json`. Read the Modal +plugin's command reference for required arguments, reviewed dirty contexts, +revision CAS, explicit rebuilds, and garbage collection grace periods. + +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. A plugin can follow +long-running jobs with bounded `experimental_continue` response pages; stopping +the CLI stops the reader, not the job. diff --git a/packages/test-helpers/src/domain-fixtures.ts b/packages/test-helpers/src/domain-fixtures.ts index 48cbd2a3a7..d095578247 100644 --- a/packages/test-helpers/src/domain-fixtures.ts +++ b/packages/test-helpers/src/domain-fixtures.ts @@ -65,7 +65,15 @@ export function makeHost(overrides: Partial = {}): Host { id: "host_test", name: "Test host", status: "connected", - type: "persistent", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + 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 488e4237a2..d43414531d 100644 --- a/plugins/account-pool/src/server.test.ts +++ b/plugins/account-pool/src/server.test.ts @@ -621,6 +621,31 @@ describe("Account Pool plugin", () => { label: "Proxied", statusMessage: "Credentials are provided by the Account Pooler hub.", }); + const readiness = await host.harness.behavior.resolveProviderEnvHealth( + "codex", + { + hostId: "host-one", + experimental_readiness: { threadId: "thread-ready" }, + }, + ); + expect(readiness?.experimental_probe?.serverPath).toBe( + "/api/v1/plugins/account-pool/http/readiness/codex", + ); + const probe = await host.harness.behavior.fetchHttp( + "GET", + "/readiness/codex", + { + headers: readiness?.experimental_probe?.headers, + }, + ); + expect(probe.status).toBe(200); + expect(await probe.json()).toEqual({ ready: true }); + const unauthenticatedProbe = await host.harness.behavior.fetchHttp( + "GET", + "/readiness/codex", + {}, + ); + expect(unauthenticatedProbe.status).toBe(401); const httpResponse = await host.harness.behavior.fetchHttp( "POST", "/v1/responses", diff --git a/plugins/account-pool/src/server.ts b/plugins/account-pool/src/server.ts index 85ee6d3f64..f7554043e0 100644 --- a/plugins/account-pool/src/server.ts +++ b/plugins/account-pool/src/server.ts @@ -203,15 +203,32 @@ export function createAccountPoolPlugin( }, ]; }); - bb.providers.experimental_contributeEnvHealth("claude-code", async () => - (await operations.isRoutingEnabled("claude")) && - (await operations.hasUsableEnabledAccount("claude")) - ? { - label: "Proxied", - statusMessage: - "Credentials are provided by the Account Pooler hub.", - } - : null, + bb.providers.experimental_contributeEnvHealth( + "claude-code", + async (context) => { + if ( + !(await operations.isRoutingEnabled("claude")) || + !(await operations.hasUsableEnabledAccount("claude")) || + (context.experimental_readiness?.threadId && + (await routing.isBypassed(context.experimental_readiness.threadId))) + ) + return null; + return { + label: "Proxied", + statusMessage: "Credentials are provided by the Account Pooler hub.", + ...(context.experimental_readiness + ? { + experimental_probe: { + serverPath: + "/api/v1/plugins/account-pool/http/readiness/claude", + headers: { + authorization: `Bearer ${await hubTokens.forHost(context.hostId)}`, + }, + }, + } + : {}), + }; + }, ); bb.providers.experimental_contributeEnv("codex", async (context) => { if ( @@ -239,16 +256,29 @@ export function createAccountPoolPlugin( }, ]; }); - bb.providers.experimental_contributeEnvHealth("codex", async () => - (await operations.isRoutingEnabled("codex")) && - (await operations.hasUsableEnabledAccount("codex")) - ? { - label: "Proxied", - statusMessage: - "Credentials are provided by the Account Pooler hub.", - } - : null, - ); + bb.providers.experimental_contributeEnvHealth("codex", async (context) => { + if ( + !(await operations.isRoutingEnabled("codex")) || + !(await operations.hasUsableEnabledAccount("codex")) || + (context.experimental_readiness?.threadId && + (await routing.isBypassed(context.experimental_readiness.threadId))) + ) + return null; + return { + label: "Proxied", + statusMessage: "Credentials are provided by the Account Pooler hub.", + ...(context.experimental_readiness + ? { + experimental_probe: { + serverPath: "/api/v1/plugins/account-pool/http/readiness/codex", + headers: { + authorization: `Bearer ${await hubTokens.forHost(context.hostId)}`, + }, + }, + } + : {}), + }; + }); bb.onDispose(async () => { codexLogin.dispose(); let timer: ReturnType | null = null; @@ -306,6 +336,24 @@ export function createAccountPoolPlugin( (context) => createCodexWebSocketHandlers(context, hub, bb.log), { auth: "none" }, ); + for (const family of ["claude", "codex"] as const) { + bb.http.route( + "GET", + `/readiness/${family}`, + async (context) => { + const token = + context.req.header("authorization")?.replace(/^Bearer /, "") ?? + null; + const hostId = await hubTokens.authenticate(token); + const ready = + hostId !== null && + (await operations.isRoutingEnabled(family)) && + (await operations.hasUsableEnabledAccount(family)); + return context.json({ ready }, ready ? 200 : 401); + }, + { auth: "none" }, + ); + } bb.http.route("HEAD", "/api/hello", () => helloResponse(), { auth: "none", }); 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-official.json b/plugins/bb-official.json index 3e541ca657..e08e81b6fa 100644 --- a/plugins/bb-official.json +++ b/plugins/bb-official.json @@ -133,5 +133,9 @@ "environment-personal-workspace": { "category": "environments", "screenshots": [] + }, + "machine-digitalocean": { + "category": "environments", + "screenshots": [] } } diff --git a/plugins/concurrency-limit/server.test.ts b/plugins/concurrency-limit/server.test.ts index a451a5e75b..0453af2666 100644 --- a/plugins/concurrency-limit/server.test.ts +++ b/plugins/concurrency-limit/server.test.ts @@ -39,7 +39,15 @@ function hostRecord( id, name, status, - type: "persistent", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, diff --git a/plugins/environment-git-worktree/server.test.ts b/plugins/environment-git-worktree/server.test.ts index 8cf4255cad..6f96120cdd 100644 --- a/plugins/environment-git-worktree/server.test.ts +++ b/plugins/environment-git-worktree/server.test.ts @@ -27,7 +27,15 @@ const PROVISION_HOST: Host = { id: HOST_ID, name: "Fake machine", status: "connected", - type: "persistent", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, diff --git a/plugins/environment-personal-workspace/server.test.ts b/plugins/environment-personal-workspace/server.test.ts index 79b0c10cfd..373320c1f5 100644 --- a/plugins/environment-personal-workspace/server.test.ts +++ b/plugins/environment-personal-workspace/server.test.ts @@ -29,7 +29,15 @@ const PROVISION_HOST: Host = { id: HOST_ID, name: "Fake machine", status: "connected", - type: "persistent", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, diff --git a/plugins/environment-project-checkout/server.test.ts b/plugins/environment-project-checkout/server.test.ts index 8a259b2a7f..a5295bca4d 100644 --- a/plugins/environment-project-checkout/server.test.ts +++ b/plugins/environment-project-checkout/server.test.ts @@ -2,7 +2,10 @@ import type { PluginEnvironmentProviderCreateContext, PluginEnvironmentProviderValidateContext, } from "@get-bb/plugin-sdk/environment-provider"; -import { createFakePluginHost } from "@get-bb/plugin-sdk/testing"; +import { + createFakePluginHost, + makeThreadResponse, +} from "@get-bb/plugin-sdk/testing"; import { describe, expect, it } from "vitest"; import { PROJECT_CHECKOUT_ENVIRONMENT_PROVIDER_ID } from "./provider-id.js"; import plugin from "./server.js"; @@ -16,7 +19,15 @@ const HOST: NonNullable = { id: "host-a", name: "Fake machine", status: "connected", - type: "persistent", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, @@ -284,3 +295,47 @@ describe("checkout provider validate", () => { expect(decision.action).toBe("refuse"); }); }); + +it.each([false, true])( + "reports source ownership %s to core's hook policy", + async (owned) => { + const { bb, harness } = createFakePluginHost({ + pluginId: "environment-project-checkout", + experimental_callHostRpc: (call) => { + if (call.method !== "attach") throw new Error("Unexpected host method"); + return { status: "attached", path: CHECKOUT_PATH, branchName: "main" }; + }, + sdk: { environments: { list: () => [] }, threads: { list: () => [] } }, + }); + try { + await plugin(bb); + const provider = harness.registrations.environmentProviders.get( + PROJECT_CHECKOUT_ENVIRONMENT_PROVIDER_ID, + ); + if (!provider) throw new Error("Missing provider"); + const result = await provider.create({ + project: PROJECT, + host: HOST, + projectCheckout: { path: CHECKOUT_PATH, experimental_ownsPath: owned }, + gitRemote: null, + inputs: {}, + thread: makeThreadResponse(), + suggestedBranchName: "bb/test", + attempt: 1, + pathKey: "fixture", + rebuild: false, + experimental_claimPath: async () => true, + previous: null, + report: { step() {}, log() {} }, + signal: new AbortController().signal, + }); + expect(result).toMatchObject({ + status: "created", + path: CHECKOUT_PATH, + ownsPath: owned, + }); + } finally { + await harness.lifecycle.dispose(); + } + }, +); diff --git a/plugins/environment-project-checkout/server.ts b/plugins/environment-project-checkout/server.ts index d2a1fc0a38..94aebd6c97 100644 --- a/plugins/environment-project-checkout/server.ts +++ b/plugins/environment-project-checkout/server.ts @@ -232,7 +232,9 @@ export default async function checkoutPlugin(bb: BbPluginApi): Promise { return { status: "created", path: result.path, - ownsPath: false, + ownsPath: + result.path === context.projectCheckout.path && + context.projectCheckout.experimental_ownsPath === true, }; } catch (error) { if (context.signal.aborted) throw error; diff --git a/plugins/keep-awake/server.test.ts b/plugins/keep-awake/server.test.ts index a432cfc6e7..0aef94f539 100644 --- a/plugins/keep-awake/server.test.ts +++ b/plugins/keep-awake/server.test.ts @@ -37,7 +37,15 @@ function hostRecord( id, name: id, status, - type: "persistent", + machineProviderId: null, + machineProviderSelection: null, + lifecycle: { + phase: "active", + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, maxPermissionMode: "full", lastSeenAt: null, lastRejectedProtocolVersion: null, diff --git a/plugins/machine-digitalocean/README.md b/plugins/machine-digitalocean/README.md new file mode 100644 index 0000000000..e90a277449 --- /dev/null +++ b/plugins/machine-digitalocean/README.md @@ -0,0 +1,194 @@ +# DigitalOcean machines + +Catalog plugin providing the `digitalocean` machine provider. Create a standalone +machine from Settings → Machines or through `bb machine create` / the public +machines SDK using this provider. Configure project sources and agent credentials +on the resulting machine separately. The plugin does not create an environment +provider or copy a project checkout. + +Create the dev box from Settings → Machines. Once enrolled, it appears as a +machine section in the composer picker, like SSH/Tailscale machines. DigitalOcean +does not declare a new-machine/project-checkout shortcut in that picker. +Standalone CLI and SDK creation require no project: + +```sh +bb machine create --provider digitalocean --inputs '{}' --json +``` + +```ts +await bb.sdk.hosts.create({ machineProviderId: "digitalocean", projectId: null, inputs: {} }); +``` + +Core requires an inputs object for providers with an input schema; an empty +object selects the DigitalOcean defaults. Configure project sources or select +Personal workspace on the enrolled machine separately. + +Set the secret plugin setting `DIGITALOCEAN_TOKEN`. The token needs Droplet read, +create, update and delete permissions, image/snapshot read and delete, snapshot creation, reserved-IP read, and tag creation. Each launch +accepts nonsecret `region` and `size` inputs, defaulting to `nyc3` and +`s-2vcpu-4gb`. The Machines-page create picker exposes editable Region and Size +fields with those same defaults. Enter DigitalOcean region and size slugs using +lowercase letters, numbers, and hyphens. Invalid input blocks creation until +corrected. The image is `ubuntu-24-04-x64`. Configure the instance's default +server-access provider before creating machines. + +The plugin prepares core enrollment, obtains the public installer command, and +passes its private stdin through cloud-init user data. Cloud-init writes the +bundle to a root-owned 0600 file, feeds it to the installer, removes the file, +and suppresses installer output. It first installs the official x64 Node 22.23.2 +binary (with npm), verifies its pinned SHA-256, removes the downloaded archive +before service installation, and adds `/usr/local/bin` to PATH. On the 512 MB +Ubuntu image, retaining the archive in `/run` exhausted systemd’s free-space +safety buffer and prevented daemon service installation. The default 4 GB size +is suitable for running agents; a 512 MB image can bootstrap but was observed +killing the daemon under memory pressure after connection. +Core's installer enrolls the machine and +installs the persistent service. Credentials are absent from plugin progress, +resource records, allocation intents, and command arguments. DigitalOcean and +root on the Droplet can access user data; the enrollment credential is one-time +and short-lived. + +## Long-lived dev boxes + +Every sleep now quiesces through core, gracefully shuts down (`shutdown`), confirms +off, snapshots the disk and remains off. Busy threads and open terminals prevent +sleep; finish work and close terminals first. Snapshot names encode host identity +and UTC time. Metadata (ID, size and creation time) is durable before pruning. +Snapshot intent belongs to a shutdown generation. After any wake, the next sleep +creates a fresh backup; only retries within the same shutdown reconcile an +uncertain submission. If backup fails after shutdown, status reads **off, backup failed**, the machine +remains suspended and prior backups survive. Retry after waking. Snapshot now +also sleeps the machine; it requires an active machine. This is disk backup, +not a memory checkpoint. Attached volumes need separate protection. + +Retention defaults to 2; the plugin setting `snapshotRetention` (1–100) supplies +new-machine defaults. Each box can override `retention`. Only snapshots with the +plugin's host-specific ownership name and matching Droplet resource ID are +pruned. Remove deletes the Droplet and all its plugin-owned snapshots; manual +snapshots and other machines' snapshots survive. Cleanup failures remain retryable. + +Idle stop is opt-in: creation accepts `idleMinutes` (null by default), including +in Add machine. Core's policy checks live thread activity and open terminals, +including machines with no threads, and wakes on dispatch. Automatic retirement +remains **never**. Plugin settings expose a machine selector, idle duration, +retention, weekday sleep/wake times, timezone and immediate snapshot/wake/cost. + +**Powered-off droplets still bill; snapshot storage bills per GB.** See official +[Droplet pricing](https://docs.digitalocean.com/products/droplets/details/pricing/), +[snapshot pricing](https://docs.digitalocean.com/products/snapshots/details/pricing/) +and [reserved IP pricing](https://docs.digitalocean.com/products/networking/reserved-ips/details/pricing/). +Cost is labelled an estimate: live `/v2/sizes` rates, running/off observations, +actual snapshot GB × $0.06/month and account-wide unassigned reserved IPv4 at +$5/month are shown separately. Assigned IPs are free. Machine rows/details, +`bb machine show --json`, and the plugin status/cost commands expose estimates. +Account-wide rates, snapshots and reserved IPs are shared across callers for +30 seconds; concurrent reads coalesce and mutations invalidate the cache. +Sleep JSON always includes saved power/backup status and snapshot ID. Optional +`details.values.cost` is null with `inventoryError` when inventory is unavailable; +status and the UI also retain saved backup state during vendor outages. +Tax, bandwidth, volumes and credits are excluded; this is not an invoice. + +### Configure from a local thread + +```sh +bb digitalocean configure '{"idleMinutes":60,"retention":2,"schedule":{"weekdays":[1,2,3,4,5],"sleep":"19:00","wake":"08:00","timezone":"America/Los_Angeles"}}' --json +bb digitalocean snapshot-now --json +bb digitalocean sleep --json +bb digitalocean wake --json +bb digitalocean status --json +bb digitalocean cost --json +bb machine show --json +bb digitalocean configure '{"idleMinutes":null,"retention":2,"schedule":null}' --json +``` + +Configuration replaces all three fields; omitted fields get their documented +defaults. Saving resets the schedule cursor to now and invalidates previously selected +runs. A run already dispatched through core may finish, but cannot overwrite +the replacement configuration cursor. Commands are backed by the +plugin RPC contract (`configure`, `configuration`, `machines`, `status`, `sleep`, +`wake`) through `bb.sdk.plugins.callRpc`; `snapshot-now` aliases core-backed sleep. +Core lifecycle SDK parity is `bb.sdk.hosts.suspend/resume`, and inventory is +`bb.sdk.hosts.experimental_providerDetails({hostId})`. + +The SDK's durable minute cron runs on the **always-on BB server**, never on the +sleeping box. Weekdays use Sunday=0 through Saturday=6. Schedule times require an +IANA timezone. On a missed run or restart, only the latest action in the past +eight days runs; completed cursors are durable. Busy/failed actions retry next +minute until superseded by a newer action. A wake waits for an in-progress +suspension; skipped transitional states remain pending. Waking an active box +is a no-op. Nonexistent spring-DST times are +skipped; repeated autumn times may run twice (already-off/on operations are +skipped). Vendor lifecycle operations always route through core suspend/resume. + +Creation prepares enrollment before vendor allocation and awaits a core resource +checkpoint as soon as the allocation ID is known. The checkpoint contains no +bootstrap credentials. Core can remove a cancelled allocation directly from +that checkpoint without rerunning creation, enrollment, or bootstrap. + +## Retry and cancellation + +The vendor name and tag are a deterministic hash of core's creation key. Before +POST, the plugin persists a small allocation intent. It records the returned ID +even if cancellation arrives during POST, then propagates cancellation. The +create HTTP request has a 30-second deadline; all other requests and polling +honor cancellation. Provisioning is bounded to ten minutes and power actions to +five minutes plus the initiating request. + +Retries reconcile the stored ID or tag before doing anything else. An unknown +POST outcome is never followed by another create POST for that key. The plugin +reconciles for up to 30 seconds and returns an explicit unresolved-allocation +failure if no Droplet is visible. DigitalOcean's create reference documents no +idempotency header or unique-name guarantee. A crash after persisting intent but +before POST can therefore leave an unresolved allocation that requires operator +reconciliation. Do not clear that intent until vendor state has been established. + +Core owns enrollment/access cleanup. If cloud-init never completes before its +one-time bootstrap expires, this provider has no remote exec channel to deliver +a replacement credential. Remove/reconcile the failed allocation and start a +new machine. A normally enrolled Droplet resumes using its durable credentials. + +## Vendor contract references + +Request fields and behavior were checked against DigitalOcean's primary API +reference and OpenAPI specification: + +- [Create a Droplet](https://github.com/digitalocean/openapi/blob/main/specification/resources/droplets/droplets_create.yml) +- [Create fields, user-data limit, and permissions](https://github.com/digitalocean/openapi/blob/main/specification/resources/droplets/models/droplet_create.yml) +- [Tag-filtered lookup](https://github.com/digitalocean/openapi/blob/main/specification/resources/droplets/droplets_list.yml) +- [Power actions](https://github.com/digitalocean/openapi/blob/main/specification/resources/droplets/dropletActions_post.yml) +- [Action status](https://github.com/digitalocean/openapi/blob/main/specification/resources/droplets/dropletActions_get.yml) +- [Delete a Droplet](https://github.com/digitalocean/openapi/blob/main/specification/resources/droplets/droplets_destroy.yml) + +Live verification includes core enrollment, project checkout and Git worktree threads, allocation cancellation, and vendor-confirmed cleanup. + +Node runtime checksum: [official Node 22.23.2 SHA-256 manifest](https://nodejs.org/dist/v22.23.2/SHASUMS256.txt). + +The daemon installer does not install an agent CLI. Preinstall the chosen agent +in the image/template, or run `bb machine provider-cli install codex` +and retry the thread. Account Pooler can supply runtime authentication through +the selected server-access grant. + +## Logo and trademark + +`digitalocean-logo.svg` uses the official DO icon from +[DigitalOcean's press page](https://www.digitalocean.com/press), specifically +`DO Logo Assets/SVG/DO_Logo_icon_black.svg` in its +[logo archive](https://web-platforms.sfo2.cdn.digitaloceanspaces.com/DO%20Logo%20Assets.zip). +The geometry and square viewBox are preserved; editor metadata and redundant +groups are removed, and the single fill inherits `currentColor` for both themes. + +The archive contains no separate logo license. DigitalOcean's +[Trademark Usage Guidelines](https://www.digitalocean.com/legal/trademark-usage-guidelines) +reserve the marks, require accurate identification without implied endorsement, +and require express permission for logo use except as authorized by those +guidelines. The mark identifies the service this plugin integrates with; it is +not covered by bb's code license. This product is not affiliated with or +endorsed by DigitalOcean, LLC. + +## Local verification + +From a fresh checkout, run `pnpm install --frozen-lockfile` at the repository +root before `pnpm exec turbo run test --filter=bb-plugin-machine-digitalocean`. +The workspace install links this plugin's declared Zod, Plugin SDK/testing, +and React testing dependencies; running Vitest directly before installation +cannot resolve those local dependencies. diff --git a/plugins/machine-digitalocean/app.test.tsx b/plugins/machine-digitalocean/app.test.tsx new file mode 100644 index 0000000000..ec4f2bdaaa --- /dev/null +++ b/plugins/machine-digitalocean/app.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment jsdom +import { cleanup, fireEvent, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { loadPluginApp, renderSlot } from "@get-bb/plugin-sdk/testing/app"; +import type { + JsonValue, + PluginMachineProviderInputsProps, +} from "@get-bb/plugin-sdk/app"; +import { inputsSchema } from "./inputs.js"; + +const app = await loadPluginApp(() => import("./app.js")); +afterEach(cleanup); + +function renderInputs(value: JsonValue | null = null) { + const registration = app.machineProviderInputs.find( + (slot) => slot.machineProviderId === "digitalocean", + ); + if (registration === undefined) + throw new Error("DigitalOcean inputs slot missing"); + const onChange = vi.fn(); + const slot = renderSlot(registration, { + projectId: null, + value, + onChange, + }); + return { slot, onChange }; +} + +describe("DigitalOcean machine inputs", () => { + it("makes the server defaults ready in the Machines-page picker", async () => { + const { slot, onChange } = renderInputs(); + expect(slot.getByLabelText("Region")).toHaveProperty("value", "nyc3"); + expect(slot.getByLabelText("Size")).toHaveProperty("value", "s-2vcpu-4gb"); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + status: "ready", + value: inputsSchema.parse({}), + }), + ); + }); + it("restores a saved machine selection", async () => { + const value = { region: "ams3", size: "s-4vcpu-8gb", idleMinutes: null }; + const { slot, onChange } = renderInputs(value); + expect(slot.getByLabelText("Region")).toHaveProperty("value", value.region); + expect(slot.getByLabelText("Size")).toHaveProperty("value", value.size); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ status: "ready", value }), + ); + }); + it("passes edited region and size together without losing the other field", async () => { + const { slot, onChange } = renderInputs(); + fireEvent.change(slot.getByLabelText("Region"), { + target: { value: "sfo3" }, + }); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + status: "ready", + value: { region: "sfo3", size: "s-2vcpu-4gb", idleMinutes: null }, + }), + ); + fireEvent.change(slot.getByLabelText("Size"), { + target: { value: "s-4vcpu-8gb" }, + }); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + status: "ready", + value: { region: "sfo3", size: "s-4vcpu-8gb", idleMinutes: null }, + }), + ); + }); + it.each([ + { field: "Region", invalid: "", valid: "nyc3" }, + { field: "Size", invalid: "4 vCPU", valid: "s-2vcpu-4gb" }, + ])( + "blocks invalid $field and becomes ready after correction", + async ({ field, invalid, valid }) => { + const { slot, onChange } = renderInputs(); + fireEvent.change(slot.getByLabelText(field), { + target: { value: invalid }, + }); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + status: "blocked", + reason: `${field} must use lowercase letters, numbers, and hyphens.`, + }), + ); + expect(slot.getByRole("alert").textContent).toContain(field); + fireEvent.change(slot.getByLabelText(field), { + target: { value: valid }, + }); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + status: "ready", + value: inputsSchema.parse({}), + }), + ); + expect(slot.queryByRole("alert")).toBeNull(); + }, + ); + it("keeps an invalid saved value visible and blocks creation", async () => { + const { slot, onChange } = renderInputs({ + region: "NYC 3", + size: "s-2vcpu-4gb", + }); + expect(slot.getByLabelText("Region")).toHaveProperty("value", "NYC 3"); + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith({ + status: "blocked", + reason: "Region must use lowercase letters, numbers, and hyphens.", + }), + ); + }); +}); diff --git a/plugins/machine-digitalocean/app.tsx b/plugins/machine-digitalocean/app.tsx new file mode 100644 index 0000000000..b9a53948b8 --- /dev/null +++ b/plugins/machine-digitalocean/app.tsx @@ -0,0 +1,120 @@ +import { DevboxSettings } from "./settings.js"; +import { useEffect, useRef, useState } from "react"; +import { Input } from "@bb/shared-ui/input"; +import { + definePluginApp, + type JsonValue, + type PluginMachineProviderInputsProps, +} from "@get-bb/plugin-sdk/app"; +import { inputsSchema } from "./inputs.js"; + +function initialInputs(value: JsonValue | null) { + const defaults = inputsSchema.parse({}); + if (typeof value !== "object" || value === null || Array.isArray(value)) + return defaults; + return { + idleMinutes: + typeof value.idleMinutes === "number" ? value.idleMinutes : null, + region: typeof value.region === "string" ? value.region : defaults.region, + size: typeof value.size === "string" ? value.size : defaults.size, + }; +} + +function DigitalOceanInputs({ + value, + onChange, +}: PluginMachineProviderInputsProps) { + const [inputs, setInputs] = useState(() => initialInputs(value)); + const onChangeRef = useRef(onChange); + useEffect(() => { + onChangeRef.current = onChange; + }, [onChange]); + const validation = inputsSchema.safeParse(inputs); + const error = validation.success + ? null + : (validation.error.issues[0]?.message ?? "Enter a valid region and size."); + + useEffect(() => { + onChangeRef.current( + error === null + ? { status: "ready", value: inputs } + : { status: "blocked", reason: error }, + ); + }, [inputs, error]); + + return ( +
+ + + +

+ Powered-off droplets still bill; snapshot storage bills per GB.{" "} + + Droplet pricing + {" "} + ·{" "} + + Snapshot pricing + +

+ {error !== null && ( + + {error} + + )} +
+ ); +} + +export default definePluginApp((app) => { + app.slots.settingsSection({ id: "devboxes", component: DevboxSettings }); + app.slots.experimental_machineProviderInputs({ + machineProviderId: "digitalocean", + component: DigitalOceanInputs, + }); +}); diff --git a/plugins/machine-digitalocean/cloud-init.test.ts b/plugins/machine-digitalocean/cloud-init.test.ts new file mode 100644 index 0000000000..4f721139a4 --- /dev/null +++ b/plugins/machine-digitalocean/cloud-init.test.ts @@ -0,0 +1,66 @@ +import { execFileSync } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { cloudInit } from "./cloud-init.js"; + +describe("cloud-init delivery", () => { + it("writes bootstrap privately and feeds installer stdin without secret command arguments", () => { + const data = cloudInit({ + command: ["sh", "-c", "cat >/dev/null"], + stdin: "credential-secret", + }); + expect(data).toContain('"permissions":"0600"'); + expect(data).toContain(Buffer.from("credential-secret").toString("base64")); + expect(data).not.toContain("credential-secret"); + expect(data).toContain("rm -f /run/bb-enrollment.stdin"); + expect(data).toContain("< /run/bb-enrollment.stdin >/dev/null 2>&1"); + }); + it("releases the Node archive before systemd installs on a 512 MB box", () => { + const data = cloudInit({ command: ["bb-installer"], stdin: "secret" }); + const config = z + .object({ + runcmd: z.array(z.tuple([z.string(), z.string(), z.string()])), + }) + .parse(JSON.parse(data.slice("#cloud-config\n".length))); + const script = config.runcmd[0]?.[2] ?? ""; + const extract = script.indexOf("tar -xJf /run/bb-node.tar.xz"); + const release = script.indexOf("\nrm -f /run/bb-node.tar.xz\n"); + expect(extract).toBeGreaterThan(0); + expect(release).toBeGreaterThan(extract); + expect(script.indexOf("'bb-installer'")).toBeGreaterThan(release); + }); + it("keeps installer metacharacters quoted and rejects oversized user-data", () => { + const data = cloudInit({ + command: ["printf", "%s", "it's $(id)"], + stdin: "", + }); + expect(data).toContain("$(id)"); + const config = z + .object({ + runcmd: z.array(z.tuple([z.string(), z.string(), z.string()])), + }) + .parse(JSON.parse(data.slice("#cloud-config\n".length))); + execFileSync("sh", ["-n"], { input: config.runcmd[0]?.[2] }); + expect(() => + cloudInit({ command: ["true"], stdin: "x".repeat(64 * 1024) }), + ).toThrow("64 KiB"); + }); + it("frees the Node archive from /run before installing the systemd service", () => { + const config = z + .object({ + runcmd: z.array(z.tuple([z.string(), z.string(), z.string()])), + }) + .parse( + JSON.parse( + cloudInit({ command: ["install-daemon"], stdin: "" }).slice( + "#cloud-config\n".length, + ), + ), + ); + const script = config.runcmd[0]![2]; + const extraction = script.indexOf("tar -xJf"); + const cleanup = script.indexOf("rm -f /run/bb-node.tar.xz", extraction); + expect(cleanup).toBeGreaterThan(extraction); + expect(cleanup).toBeLessThan(script.indexOf("'install-daemon'")); + }); +}); diff --git a/plugins/machine-digitalocean/cloud-init.ts b/plugins/machine-digitalocean/cloud-init.ts new file mode 100644 index 0000000000..0dd08f82b0 --- /dev/null +++ b/plugins/machine-digitalocean/cloud-init.ts @@ -0,0 +1,55 @@ +function quote(value: string): string { + return `'${value.replaceAll("'", `'\\''`)}'`; +} + +export function cloudInit(installer: { + command: string[]; + stdin: string; +}): string { + const path = "/run/bb-enrollment.stdin"; + const data = + "#cloud-config\n" + + JSON.stringify({ + package_update: true, + packages: [ + "ca-certificates", + "curl", + "xz-utils", + "git", + "build-essential", + ], + write_files: [ + { + path, + owner: "root:root", + permissions: "0600", + encoding: "b64", + content: Buffer.from(installer.stdin).toString("base64"), + }, + ], + runcmd: [ + [ + "sh", + "-c", + [ + "set -eu", + "umask 077", + `trap 'rm -f ${path} /run/bb-node.tar.xz' EXIT`, + "curl -fsSL --connect-timeout 10 --max-time 180 --retry 2 https://nodejs.org/dist/v22.23.2/node-v22.23.2-linux-x64.tar.xz -o /run/bb-node.tar.xz", + "echo 'd60acfe00a2932254bb0ad20e01b0d74397a0875595de719654b214f4b03f307 /run/bb-node.tar.xz' | sha256sum -c -", + "tar -xJf /run/bb-node.tar.xz -C /usr/local --strip-components=1", + "rm -f /run/bb-node.tar.xz", + 'export PATH="/usr/local/bin:$PATH"', + "node --version", + "npm --version", + `${installer.command.map(quote).join(" ")} < ${path} >/dev/null 2>&1`, + ].join("\n"), + ], + ], + }); + if (Buffer.byteLength(data) > 64 * 1024) + throw new Error( + "DigitalOcean cloud-init exceeds the 64 KiB user-data limit.", + ); + return data; +} diff --git a/plugins/machine-digitalocean/devbox.test.ts b/plugins/machine-digitalocean/devbox.test.ts new file mode 100644 index 0000000000..b53ca83f00 --- /dev/null +++ b/plugins/machine-digitalocean/devbox.test.ts @@ -0,0 +1,282 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createConnection, + migrate, + getPluginKvValue, + setPluginKvValue, + deletePluginKvValue, + listPluginKvKeys, +} from "@bb/db"; +import type { PluginKvStorage } from "@get-bb/plugin-sdk"; +import { + computeCost, + createDevboxStore, + latestScheduledAction, + pruneSnapshots, + sleepWithBackup, +} from "./devbox.js"; +import type { Droplet, Snapshot, Vendor } from "./vendor.js"; + +const databases: ReturnType[] = []; +afterEach(() => { + for (const db of databases.splice(0)) db.$client.close(); +}); +function setup() { + const db = createConnection(":memory:"); + migrate(db); + databases.push(db); + const kv: PluginKvStorage = { + async get(key: string): Promise { + const raw = getPluginKvValue(db, "digitalocean", key); + return raw === undefined ? undefined : JSON.parse(raw); + }, + async set(key, value) { + setPluginKvValue(db, "digitalocean", key, JSON.stringify(value)); + }, + async delete(key) { + deletePluginKvValue(db, "digitalocean", key); + }, + async list(prefix) { + return listPluginKvKeys(db, "digitalocean", prefix); + }, + }; + const store = createDevboxStore(kv, () => 1_000_000); + const order: string[] = []; + const droplet: Droplet = { id: 42, name: "test", tags: [], status: "active" }; + const snapshots: Snapshot[] = []; + const api = { + get: vi.fn(async () => ({ ...droplet })), + find: vi.fn(async () => ({ ...droplet })), + create: vi.fn(async () => ({ ...droplet })), + power: vi.fn(async () => { + order.push("shutdown"); + droplet.status = "off"; + }), + snapshots: vi.fn(async () => [...snapshots]), + snapshot: vi.fn(async (_id: number, name: string) => { + order.push("snapshot"); + expect(droplet.status).toBe("off"); + snapshots.push({ + id: "new", + name, + size_gigabytes: 2.5, + created_at: "2026-09-07T12:00:00Z", + resource_id: "42", + }); + }), + deleteSnapshot: vi.fn(async (id: string) => { + order.push(`delete:${id}`); + snapshots.splice( + snapshots.findIndex((item) => item.id === id), + 1, + ); + }), + destroy: vi.fn(async () => {}), + inventory: vi.fn(async () => ({ + droplet: null, + sizes: [], + snapshots, + reservedIps: [], + })), + } satisfies Vendor; + return { + kv, + store, + order, + droplet, + snapshots, + api, + args: { + hostId: "host-test", + dropletId: 42, + api, + store, + signal: new AbortController().signal, + now: () => 1_000_000, + }, + }; +} +const snapshot = ( + id: string, + name = `bb-devbox-host-test-${id}`, + resource_id = "42", +): Snapshot => ({ + id, + name, + resource_id, + size_gigabytes: 1, + created_at: `2026-09-0${id}T12:00:00Z`, +}); + +describe("durable devbox backups", () => { + it("confirms graceful shutdown before snapshot and persists metadata across store restart", async () => { + const test = setup(); + await sleepWithBackup(test.args); + expect(test.order).toEqual(["shutdown", "snapshot"]); + expect(test.droplet.status).toBe("off"); + const restarted = createDevboxStore(test.kv); + expect(await restarted.get("host-test")).toMatchObject({ + power: "off", + backupStatus: "complete", + snapshots: [ + { + id: "new", + size_gigabytes: 2.5, + name: "bb-devbox-host-test-1000000-1", + }, + ], + }); + }); + it("keeps the previous snapshot and records off, backup failed after shutdown", async () => { + const test = setup(); + const previous = snapshot("1"); + test.snapshots.push(previous); + await test.store.set("host-test", { + ...(await test.store.get("host-test")), + snapshots: [previous], + }); + test.api.snapshot.mockRejectedValueOnce(new Error("vendor failure")); + await sleepWithBackup(test.args); + expect(test.droplet.status).toBe("off"); + expect(await test.store.get("host-test")).toMatchObject({ + backupStatus: "off, backup failed", + snapshots: [previous], + }); + expect(test.api.deleteSnapshot).not.toHaveBeenCalled(); + }); + it("reconciles a successful but disconnected submission by its durable name", async () => { + const test = setup(); + test.api.snapshot.mockImplementationOnce(async (_id, name) => { + test.snapshots.push({ ...snapshot("1"), name }); + throw new Error("response lost"); + }); + await sleepWithBackup(test.args); + await sleepWithBackup(test.args); + expect(test.api.snapshot).toHaveBeenCalledOnce(); + expect((await test.store.get("host-test")).backupStatus).toBe("complete"); + }); + it("creates a fresh shutdown-generation backup after waking from a lost snapshot response", async () => { + const test = setup(); + test.api.snapshot.mockImplementationOnce(async (_id, name) => { + test.snapshots.push({ ...snapshot("1"), name }); + throw new Error("response lost after vendor completion"); + }); + await sleepWithBackup(test.args); + expect((await test.store.get("host-test")).backupStatus).toBe( + "off, backup failed", + ); + test.droplet.status = "active"; + const restarted = createDevboxStore(test.kv); + await sleepWithBackup({ ...test.args, store: restarted }); + expect(test.api.power).toHaveBeenCalledTimes(2); + expect(test.api.snapshot).toHaveBeenCalledTimes(2); + expect(test.api.snapshot.mock.calls.map((call) => call[1])).toEqual([ + "bb-devbox-host-test-1000000-1", + "bb-devbox-host-test-1000000-2", + ]); + expect(await restarted.get("host-test")).toMatchObject({ + backupStatus: "complete", + shutdownGeneration: 2, + pendingSnapshot: null, + snapshots: [{ id: "new", name: "bb-devbox-host-test-1000000-2" }], + }); + }); + it("prunes old owned snapshots only, after recording the new snapshot", async () => { + const test = setup(); + test.snapshots.push( + snapshot("1"), + snapshot("2"), + snapshot("3", "user-backup"), + snapshot("4", "bb-devbox-other-4", "90"), + ); + test.api.deleteSnapshot.mockImplementation(async (id) => { + expect( + (await test.store.get("host-test")).snapshots.some( + (item) => item.id === "new", + ), + ).toBe(true); + test.order.push(`delete:${id}`); + }); + await sleepWithBackup(test.args); + expect(test.order).toEqual(["shutdown", "snapshot", "delete:1"]); + expect(test.api.deleteSnapshot).toHaveBeenCalledOnce(); + }); + it("remove prunes owned snapshots while preserving user and other machine inventory", async () => { + const test = setup(); + test.snapshots.push( + snapshot("1"), + snapshot("2", "manual"), + snapshot("3", "bb-devbox-other-3", "90"), + ); + await pruneSnapshots({ ...test.args, keep: 0 }); + expect(test.snapshots.map((item) => item.id)).toEqual(["2", "3"]); + }); +}); + +describe("weekday scheduling", () => { + const schedule = { + weekdays: [1, 2, 3, 4, 5], + sleep: "19:00", + wake: "08:00", + timezone: "America/Los_Angeles", + }; + it("fires in the explicit timezone and ignores an already completed minute", () => { + const at = Date.parse("2026-09-08T02:00:00Z"); + expect(latestScheduledAction(schedule, at - 60_000, at)).toEqual({ + at, + action: "sleep", + }); + expect(latestScheduledAction(schedule, at, at)).toBeNull(); + }); + it("catches up only the latest missed action, bounded to eight days", () => { + const now = Date.parse("2026-09-08T18:00:00Z"); + expect(latestScheduledAction(schedule, now - 4 * 86_400_000, now)).toEqual({ + at: Date.parse("2026-09-08T15:00:00Z"), + action: "wake", + }); + }); + it("skips nonexistent DST local times", () => { + const spring = { ...schedule, weekdays: [0], wake: "02:30" }; + expect( + latestScheduledAction( + spring, + Date.parse("2026-03-08T08:00:00Z"), + Date.parse("2026-03-08T12:00:00Z"), + ), + ).toBeNull(); + }); +}); + +it("estimates off compute, actual snapshot GB and account-wide unassigned IP charges", async () => { + const test = setup(); + const state = await test.store.get("host-test"); + state.power = "off"; + state.powerSince = 0; + const result = computeCost( + { + droplet: { + ...test.droplet, + status: "off", + created_at: "1970-01-01T00:00:00Z", + size_slug: "small", + }, + sizes: [{ slug: "small", price_hourly: 0.006, price_monthly: 4 }], + snapshots: [{ ...snapshot("1"), size_gigabytes: 20 }], + reservedIps: [ + { ip: "192.0.2.1", droplet: null }, + { ip: "192.0.2.2", droplet: { id: 42 } }, + ], + }, + state, + "host-test", + 3_600_000, + ); + expect(result).toMatchObject({ + dropletStatus: "off", + offHoursObserved: 1, + dropletAccruedEstimate: 0.01, + snapshotMonthlyEstimate: 1.2, + monthlyEstimate: 5.2, + accountUnassignedIpMonthlyEstimate: 5, + }); +}); diff --git a/plugins/machine-digitalocean/devbox.ts b/plugins/machine-digitalocean/devbox.ts new file mode 100644 index 0000000000..87054f5630 --- /dev/null +++ b/plugins/machine-digitalocean/devbox.ts @@ -0,0 +1,329 @@ +import { z } from "zod"; +import type { PluginKvStorage } from "@get-bb/plugin-sdk"; +import type { Vendor } from "./vendor.js"; +import { snapshotSchema } from "./snapshot.js"; + +export const BILLING = + "Estimate: powered-off droplets still bill; snapshot storage bills per GB"; +export const PRICING = + "https://docs.digitalocean.com/products/droplets/details/pricing/"; +export const scheduleSchema = z + .object({ + weekdays: z.array(z.number().int().min(0).max(6)).min(1).max(7), + sleep: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), + wake: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), + timezone: z.string().refine((value) => { + try { + new Intl.DateTimeFormat("en", { timeZone: value }); + return true; + } catch { + return false; + } + }, "Use an IANA timezone"), + }) + .strict() + .refine((value) => value.sleep !== value.wake, "Sleep and wake must differ"); +export const configSchema = z + .object({ + idleMinutes: z.number().int().min(1).max(43_200).nullable().default(null), + retention: z.number().int().min(1).max(100).default(2), + schedule: scheduleSchema.nullable().default(null), + }) + .strict(); +export type DevboxConfig = z.infer; +const stateSchema = z.object({ + config: configSchema, + snapshots: z.array(snapshotSchema), + pendingSnapshot: z.string().nullable(), + shutdownGeneration: z.number().int().nonnegative().default(0), + pendingSnapshotGeneration: z + .number() + .int() + .nonnegative() + .nullable() + .default(null), + backupStatus: z.enum(["none", "complete", "off, backup failed"]), + backupError: z.string().nullable(), + power: z.enum(["active", "off"]), + powerSince: z.number(), + runningMs: z.number().nonnegative(), + offMs: z.number().nonnegative(), + scheduleCursor: z.number(), + scheduleRevision: z.number().int().nonnegative().default(0), + scheduleError: z.string().nullable(), +}); +export type DevboxState = z.infer; + +export function createDevboxStore( + kv: PluginKvStorage, + now: () => number = Date.now, +) { + const pending = new Map>(); + return { + async exclusive(hostId: string, run: () => Promise): Promise { + const previous = pending.get(hostId) ?? Promise.resolve(); + let release = () => {}; + const done = new Promise((resolve) => { + release = resolve; + }); + pending.set(hostId, done); + await previous; + try { + return await run(); + } finally { + release(); + if (pending.get(hostId) === done) pending.delete(hostId); + } + }, + async get(hostId: string): Promise { + const stored = await kv.get(`devbox/${hostId}`); + return stored === undefined + ? { + config: configSchema.parse({}), + snapshots: [], + pendingSnapshot: null, + shutdownGeneration: 0, + pendingSnapshotGeneration: null, + backupStatus: "none", + backupError: null, + power: "active", + powerSince: now(), + runningMs: 0, + offMs: 0, + scheduleCursor: now(), + scheduleRevision: 0, + scheduleError: null, + } + : stateSchema.parse(stored); + }, + async set(hostId: string, value: DevboxState) { + await kv.set(`devbox/${hostId}`, stateSchema.parse(value)); + }, + async hosts() { + return (await kv.list("devbox/")).map((key) => key.slice(7)); + }, + async delete(hostId: string) { + await kv.delete(`devbox/${hostId}`); + }, + }; +} +export type DevboxStore = ReturnType; + +export function invalidatePendingSnapshot(state: DevboxState) { + state.pendingSnapshot = null; + state.pendingSnapshotGeneration = null; +} + +export function recordPower( + state: DevboxState, + power: "active" | "off", + now: number, +) { + if (power === "active") invalidatePendingSnapshot(state); + if (state.power === power) return; + const elapsed = Math.max(0, now - state.powerSince); + if (state.power === "active") state.runningMs += elapsed; + else state.offMs += elapsed; + state.power = power; + state.powerSince = now; +} + +export async function sleepWithBackup(args: { + hostId: string; + dropletId: number; + api: Vendor; + store: DevboxStore; + signal: AbortSignal; + now: () => number; +}) { + const { hostId, dropletId, api, store, signal, now } = args; + const state = await store.get(hostId); + const droplet = await api.get(dropletId, signal); + if (!droplet) throw new Error("DigitalOcean Droplet no longer exists."); + const wasOff = droplet.status === "off"; + if (!wasOff) { + state.shutdownGeneration += 1; + invalidatePendingSnapshot(state); + await store.set(hostId, state); + await api.power(dropletId, "shutdown", signal); + } + const off = await api.get(dropletId, signal); + if (off?.status !== "off") + throw new Error( + "DigitalOcean graceful shutdown did not leave the Droplet off.", + ); + recordPower(state, "off", now()); + await store.set(hostId, state); + if ( + wasOff && + state.backupStatus === "complete" && + state.pendingSnapshot === null + ) + return state; + if (state.pendingSnapshotGeneration !== state.shutdownGeneration) + invalidatePendingSnapshot(state); + state.pendingSnapshot ??= `bb-devbox-${hostId}-${now()}-${state.shutdownGeneration}`; + state.pendingSnapshotGeneration = state.shutdownGeneration; + await store.set(hostId, state); + try { + let snapshot = (await api.snapshots(signal)).find( + (item) => + item.name === state.pendingSnapshot && + item.resource_id === String(dropletId), + ); + if (!snapshot) { + await api.snapshot(dropletId, state.pendingSnapshot, signal); + snapshot = (await api.snapshots(signal)).find( + (item) => + item.name === state.pendingSnapshot && + item.resource_id === String(dropletId), + ); + } + if (!snapshot) + throw new Error( + "Completed snapshot is not visible in inventory; retry reconciliation.", + ); + state.snapshots = [ + ...state.snapshots.filter((item) => item.id !== snapshot.id), + snapshot, + ]; + invalidatePendingSnapshot(state); + state.backupStatus = "complete"; + state.backupError = null; + await store.set(hostId, state); + } catch { + state.backupStatus = "off, backup failed"; + state.backupError = + "Snapshot submission or inventory reconciliation failed. Previous backups retained; retry sleep after waking."; + await store.set(hostId, state); + return state; + } + try { + await pruneSnapshots({ ...args, keep: state.config.retention }); + } catch { + const current = await store.get(hostId); + current.backupError = + "Backup complete; retention cleanup failed. Older backups retained; retry cleanup on next sleep."; + await store.set(hostId, current); + } + return store.get(hostId); +} + +export async function pruneSnapshots({ + hostId, + dropletId, + api, + store, + signal, + keep, +}: { + hostId: string; + dropletId: number; + api: Vendor; + store: DevboxStore; + signal: AbortSignal; + keep: number; +}) { + const state = await store.get(hostId); + const inventory = await api.snapshots(signal); + const owned = inventory + .filter( + (item) => + item.resource_id === String(dropletId) && + item.name.startsWith(`bb-devbox-${hostId}-`), + ) + .sort( + (a, b) => + b.created_at.localeCompare(a.created_at) || b.id.localeCompare(a.id), + ); + for (const snapshot of owned.slice(keep)) { + await api.deleteSnapshot(snapshot.id, signal); + state.snapshots = state.snapshots.filter((item) => item.id !== snapshot.id); + await store.set(hostId, state); + } +} + +export function latestScheduledAction( + schedule: NonNullable, + after: number, + now: number, +): { at: number; action: "sleep" | "wake" } | null { + const formatter = new Intl.DateTimeFormat("en-US", { + timeZone: schedule.timezone, + weekday: "short", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }); + const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + const lower = Math.max(after, now - 8 * 86_400_000); + for (let at = Math.floor(now / 60_000) * 60_000; at > lower; at -= 60_000) { + const parts = formatter.formatToParts(at); + const part = (key: string) => + parts.find((item) => item.type === key)?.value; + if (!schedule.weekdays.includes(days.indexOf(part("weekday") ?? ""))) + continue; + const time = `${part("hour")}:${part("minute")}`; + if (time === schedule.sleep || time === schedule.wake) + return { at, action: time === schedule.sleep ? "sleep" : "wake" }; + } + return null; +} + +export function computeCost( + inventory: Awaited>, + state: DevboxState, + hostId: string, + now: number, +) { + const droplet = inventory.droplet; + const size = inventory.sizes.find((item) => item.slug === droplet?.size_slug); + if (droplet && !size) + throw new Error("DigitalOcean size rate is unavailable."); + const snapshots = inventory.snapshots.filter( + (item) => + item.resource_id === String(droplet?.id) || + item.name.startsWith(`bb-devbox-${hostId}-`), + ); + const snapshotGb = snapshots.reduce( + (sum, item) => sum + item.size_gigabytes, + 0, + ); + const unassignedReservedIps = inventory.reservedIps + .filter((ip) => ip.droplet === null) + .map((ip) => ip.ip); + const elapsed = Math.max(0, now - state.powerSince); + const hours = droplet + ? Math.max(0, now - Date.parse(droplet.created_at)) / 3_600_000 + : 0; + return { + label: BILLING, + pricingUrl: PRICING, + snapshotPricingUrl: + "https://docs.digitalocean.com/products/snapshots/details/pricing/", + reservedIpPricingUrl: + "https://docs.digitalocean.com/products/networking/reserved-ips/details/pricing/", + observedAt: new Date(now).toISOString(), + currency: "USD", + dropletStatus: droplet?.status ?? "absent", + runningHoursObserved: + (state.runningMs + (state.power === "active" ? elapsed : 0)) / 3_600_000, + offHoursObserved: + (state.offMs + (state.power === "off" ? elapsed : 0)) / 3_600_000, + dropletHourly: size?.price_hourly ?? 0, + dropletMonthlyCap: size?.price_monthly ?? 0, + dropletAccruedEstimate: size + ? Math.min( + Math.max(0.01, size.price_hourly * Math.max(hours, 1 / 60)), + size.price_monthly * Math.max(1, Math.ceil(hours / 672)), + ) + : 0, + snapshotGb, + snapshotMonthlyEstimate: snapshotGb * 0.06, + unassignedReservedIps, + accountUnassignedIpMonthlyEstimate: unassignedReservedIps.length * 5, + monthlyEstimate: (size?.price_monthly ?? 0) + snapshotGb * 0.06, + exclusions: + "Taxes, bandwidth, volumes, credits; unassigned reserved IPs are account-wide and listed separately. Observed running/off time begins at plugin enrollment; powered-off compute bills at the same rate. Accrued compute is an approximation across billing months.", + }; +} diff --git a/plugins/machine-digitalocean/digitalocean-logo.svg b/plugins/machine-digitalocean/digitalocean-logo.svg new file mode 100644 index 0000000000..614baa2daf --- /dev/null +++ b/plugins/machine-digitalocean/digitalocean-logo.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/plugins/machine-digitalocean/inputs.ts b/plugins/machine-digitalocean/inputs.ts new file mode 100644 index 0000000000..c65c04ec44 --- /dev/null +++ b/plugins/machine-digitalocean/inputs.ts @@ -0,0 +1,19 @@ +import { z } from "zod"; + +export const inputsSchema = z.object({ + idleMinutes: z.number().int().min(1).max(43_200).nullable().default(null), + region: z + .string() + .regex( + /^[a-z0-9-]+$/, + "Region must use lowercase letters, numbers, and hyphens.", + ) + .default("nyc3"), + size: z + .string() + .regex( + /^[a-z0-9-]+$/, + "Size must use lowercase letters, numbers, and hyphens.", + ) + .default("s-2vcpu-4gb"), +}); diff --git a/plugins/machine-digitalocean/inventory-cache.ts b/plugins/machine-digitalocean/inventory-cache.ts new file mode 100644 index 0000000000..d6e1e31252 --- /dev/null +++ b/plugins/machine-digitalocean/inventory-cache.ts @@ -0,0 +1,72 @@ +import type { Vendor } from "./vendor.js"; + +export function createReadCache(now: () => number = Date.now) { + const entries = new Map }>(); + return { + clear() { + entries.clear(); + }, + async get( + key: string, + signal: AbortSignal, + read: (signal: AbortSignal) => Promise, + ): Promise { + signal.throwIfAborted(); + let entry = entries.get(key); + if (!entry || entry.expiresAt <= now()) { + if (entries.size >= 128) { + const oldest = entries.keys().next().value; + if (oldest !== undefined) entries.delete(oldest); + } + const fresh = { + expiresAt: Infinity, + value: Promise.resolve().then(() => + read(AbortSignal.timeout(30_000)), + ), + }; + entries.set(key, fresh); + fresh.value = fresh.value.then( + (value) => { + fresh.expiresAt = now() + 30_000; + return value; + }, + (error: unknown) => { + if (entries.get(key) === fresh) entries.delete(key); + throw error; + }, + ); + entry = fresh; + } + const value = entry.value; + return new Promise((resolve, reject) => { + const abort = () => reject(signal.reason); + signal.addEventListener("abort", abort, { once: true }); + value + .then(resolve, reject) + .finally(() => signal.removeEventListener("abort", abort)); + }); + }, + }; +} + +export function cacheVendorInventory(api: Vendor, now: () => number): Vendor { + const cache = createReadCache>>(now); + async function mutate(run: () => Promise) { + cache.clear(); + try { + return await run(); + } finally { + cache.clear(); + } + } + return { + ...api, + inventory: (id, signal) => + cache.get(String(id), signal, (shared) => api.inventory(id, shared)), + create: (...args) => mutate(() => api.create(...args)), + power: (...args) => mutate(() => api.power(...args)), + snapshot: (...args) => mutate(() => api.snapshot(...args)), + deleteSnapshot: (...args) => mutate(() => api.deleteSnapshot(...args)), + destroy: (...args) => mutate(() => api.destroy(...args)), + }; +} diff --git a/plugins/machine-digitalocean/package.json b/plugins/machine-digitalocean/package.json new file mode 100644 index 0000000000..7f44e4977b --- /dev/null +++ b/plugins/machine-digitalocean/package.json @@ -0,0 +1,46 @@ +{ + "name": "bb-plugin-machine-digitalocean", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Run persistent bb machines on DigitalOcean Droplets", + "engines": { + "bb": ">=0.0" + }, + "bb": { + "name": "DigitalOcean", + "description": "Run persistent bb machines on DigitalOcean Droplets", + "server": "./server.ts", + "app": "./app.tsx", + "branding": { + "icon": "./digitalocean-logo.svg" + }, + "skills": [ + "skills" + ] + }, + "keywords": [ + "bb-plugin" + ], + "scripts": { + "lint": "oxlint .", + "test": "vitest run --config vitest.config.ts", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "zod": "^4.3.6", + "@bb/shared-ui": "workspace:*" + }, + "devDependencies": { + "@get-bb/plugin-sdk": "workspace:*", + "@types/node": "^22.0.0", + "typescript": "npm:@typescript/typescript6@^6.0.2", + "vitest": "^4.1.1", + "@testing-library/react": "^16.3.2", + "@types/react": "^19.0.0", + "jsdom": "^29.0.1", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "@bb/db": "workspace:*" + } +} diff --git a/plugins/machine-digitalocean/rpc.ts b/plugins/machine-digitalocean/rpc.ts new file mode 100644 index 0000000000..d6de5044b2 --- /dev/null +++ b/plugins/machine-digitalocean/rpc.ts @@ -0,0 +1,31 @@ +import { defineRpcContract } from "@get-bb/plugin-sdk"; +import { z } from "zod"; +import { configSchema } from "./devbox.js"; +export const hostInput = z.object({ hostId: z.string().min(1) }).strict(); +export const devboxRpc = defineRpcContract({ + configuration: { input: hostInput, output: configSchema }, + machines: { + input: z.null(), + output: z.array(z.object({ id: z.string(), name: z.string() })), + }, + status: { + input: hostInput, + output: z.object({ summary: z.string(), values: z.json() }), + }, + configure: { + input: hostInput.extend({ config: configSchema }), + output: configSchema, + }, + sleep: { + input: hostInput, + output: z.object({ + ok: z.literal(true), + power: z.enum(["active", "off"]), + backupError: z.string().nullable(), + details: z.object({ summary: z.string(), values: z.json() }), + backupStatus: z.enum(["none", "complete", "off, backup failed"]), + snapshotId: z.string().nullable(), + }), + }, + wake: { input: hostInput, output: z.object({ ok: z.literal(true) }) }, +}); diff --git a/plugins/machine-digitalocean/server.test.ts b/plugins/machine-digitalocean/server.test.ts new file mode 100644 index 0000000000..88e6150138 --- /dev/null +++ b/plugins/machine-digitalocean/server.test.ts @@ -0,0 +1,709 @@ +import { createDevboxStore } from "./devbox.js"; +import { + createConnection, + migrate, + getPluginKvValue, + setPluginKvValue, + deletePluginKvValue, + listPluginKvKeys, +} from "@bb/db"; +import type { JsonValue } from "@get-bb/plugin-sdk"; +import { createFakePluginHost } from "@get-bb/plugin-sdk/testing"; +import { describe, expect, it, vi } from "vitest"; +import { createDigitalOceanPlugin } from "./server.js"; +import { + VendorError, + allocationName, + type Droplet, + type Vendor, +} from "./vendor.js"; + +const context = () => ({ + key: "creation-key", + attempt: 1, + project: null, + gitRemote: null, + inputs: { region: "nyc3", size: "s-2vcpu-4gb" }, + report: { step: vi.fn(), log: vi.fn() }, + signal: new AbortController().signal, + checkpoint: vi.fn(async (_resource: JsonValue) => {}), +}); +const droplet = (): Droplet => ({ + id: 42, + name: allocationName("creation-key"), + tags: [allocationName("creation-key")], + status: "active", +}); + +async function setup(now: () => number = Date.now) { + let allocated: Droplet | null = null; + const snapshots: import("./vendor.js").Snapshot[] = []; + const api = { + snapshots: vi.fn(async () => snapshots), + snapshot: vi.fn(async (_id: number, name: string) => { + snapshots.push({ + id: String(snapshots.length + 1), + name, + size_gigabytes: 1, + created_at: new Date().toISOString(), + resource_id: "42", + }); + }), + deleteSnapshot: vi.fn(async (id: string) => { + const index = snapshots.findIndex((item) => item.id === id); + if (index >= 0) snapshots.splice(index, 1); + }), + inventory: vi.fn(async () => ({ + droplet: null, + sizes: [], + snapshots, + reservedIps: [], + })), + find: vi.fn(async () => allocated), + get: vi.fn(async () => allocated), + create: vi.fn(async () => { + allocated = droplet(); + return allocated; + }), + power: vi.fn(async (_id: number, action: "shutdown" | "power_on") => { + if (allocated) + allocated.status = action === "shutdown" ? "off" : "active"; + }), + destroy: vi.fn(async () => { + allocated = null; + }), + } satisfies Vendor; + const fake = createFakePluginHost({ + pluginId: "machine-digitalocean", + settings: { DIGITALOCEAN_TOKEN: "token-secret" }, + }); + const db = createConnection(":memory:"); + migrate(db); + Object.assign(fake.bb.storage.kv, { + async get(key: string) { + const value = getPluginKvValue(db, "digitalocean", key); + return value === undefined ? undefined : JSON.parse(value); + }, + async set(key: string, value: unknown) { + setPluginKvValue(db, "digitalocean", key, JSON.stringify(value)); + }, + async delete(key: string) { + deletePluginKvValue(db, "digitalocean", key); + }, + async list(prefix?: string) { + return listPluginKvKeys(db, "digitalocean", prefix); + }, + }); + fake.bb.onDispose(() => { + db.$client.close(); + }); + const prepare = vi.fn(async (_request: { key: string }) => ({ + id: "enrollment-1", + hostId: "host-1", + state: "pending", + bootstrap: { credential: "credential-secret" }, + })); + const waitForConnection = vi.fn(async () => ({ hostId: "host-1" })); + const installerCommand = vi.fn(() => ({ + command: ["sh", "-c", "install-and-enroll"], + stdin: "credential-secret", + })); + Object.assign(fake.bb.experimental_machines, { + enrollments: { prepare, waitForConnection }, + installerCommand, + }); + const sleep = vi.fn(async (_signal: AbortSignal) => {}); + await createDigitalOceanPlugin({ vendor: () => api, sleep, now })(fake.bb); + const provider = + fake.harness.registrations.machineProviders.get("digitalocean"); + if (!provider) throw new Error("missing provider"); + return { + ...fake, + api, + provider, + prepare, + waitForConnection, + installerCommand, + sleep, + }; +} + +describe("DigitalOcean machine provider", () => { + it("creates a standalone dev box without a project or composer shortcut", async () => { + const test = await setup(); + expect(test.provider.environmentRow).toBeNull(); + expect(test.provider.requires?.gitRemote ?? false).toBe(false); + const request = context(); + expect(await test.provider.create(request)).toMatchObject({ + status: "created", + }); + expect(test.api.create).toHaveBeenCalledWith( + expect.objectContaining({ region: "nyc3", size: "s-2vcpu-4gb" }), + expect.any(AbortSignal), + ); + await test.harness.lifecycle.dispose(); + }); + + it("allocates once and keeps cloud-init secrets out of resource/progress", async () => { + const test = await setup(); + const request = context(); + const first = await test.provider.create(request); + const second = await test.provider.create(request); + expect(first).toMatchObject({ + status: "created", + hostId: "host-1", + resource: { dropletId: 42, version: 1 }, + }); + expect(second).toEqual(first); + expect(test.api.create).toHaveBeenCalledOnce(); + expect(test.installerCommand).toHaveBeenCalledOnce(); + expect( + JSON.stringify([ + first, + request.report.step.mock.calls, + request.report.log.mock.calls, + ]), + ).not.toMatch(/token-secret|credential-secret/); + expect(test.provider.policy).toMatchObject({ + idleSuspendMs: null, + retire: { after: "never" }, + }); + await test.harness.lifecycle.dispose(); + }); + + it.each(["create", "lookup"])( + "checkpoints a %s result despite cancellation so core can remove without enrollment", + async (phase) => { + const test = await setup(); + const controller = new AbortController(); + const allocate = async () => { + controller.abort(new Error("cancelled")); + return droplet(); + }; + if (phase === "create") test.api.create.mockImplementationOnce(allocate); + else test.api.find.mockImplementationOnce(allocate); + const request = { ...context(), signal: controller.signal }; + await expect(test.provider.create(request)).rejects.toThrow("cancelled"); + const resource = request.checkpoint.mock.calls[0]?.[0]; + expect(resource).toEqual({ + version: 1, + key: request.key, + dropletId: 42, + enrollmentId: "enrollment-1", + }); + expect(test.waitForConnection).not.toHaveBeenCalled(); + expect(test.prepare.mock.invocationCallOrder[0]).toBeLessThan( + test.api.find.mock.invocationCallOrder[0]!, + ); + if (resource === undefined) throw new Error("missing checkpoint"); + test.api.get.mockResolvedValue(droplet()); + await test.provider.remove({ ...context(), hostId: "host-1", resource }); + expect(test.api.destroy).toHaveBeenCalledWith( + 42, + expect.any(AbortSignal), + ); + expect(test.prepare).toHaveBeenCalledOnce(); + await test.harness.lifecycle.dispose(); + }, + ); + + it("does not wait for enrollment when checkpoint persistence fails", async () => { + const test = await setup(); + const request = context(); + request.checkpoint.mockRejectedValueOnce(new Error("checkpoint failed")); + expect(await test.provider.create(request)).toMatchObject({ + status: "failed", + failure: "transient", + }); + expect(test.waitForConnection).not.toHaveBeenCalled(); + expect(test.api.destroy).not.toHaveBeenCalled(); + expect(await test.provider.create(context())).toMatchObject({ + status: "created", + }); + expect(test.api.create).toHaveBeenCalledOnce(); + await test.harness.lifecycle.dispose(); + }); + + it("reconciles unknown create outcomes by tag without posting twice", async () => { + const test = await setup(); + test.api.create.mockRejectedValueOnce( + new Error("connection lost after POST"), + ); + expect(await test.provider.create(context())).toMatchObject({ + status: "failed", + failure: "transient", + }); + test.api.find.mockResolvedValueOnce(droplet()); + expect(await test.provider.create(context())).toMatchObject({ + status: "created", + }); + expect(test.api.create).toHaveBeenCalledOnce(); + await test.harness.lifecycle.dispose(); + }); + + it("reports an unresolved intent instead of allocating another droplet", async () => { + const test = await setup(); + await test.bb.storage.kv.set( + `allocation/${allocationName("creation-key")}`, + { dropletId: null }, + ); + test.sleep.mockRejectedValueOnce(new Error("reconciliation deadline")); + expect(await test.provider.create(context())).toMatchObject({ + status: "failed", + message: expect.stringContaining("no second create"), + }); + expect(test.api.create).not.toHaveBeenCalled(); + await test.harness.lifecycle.dispose(); + }); + + it("powers off/on, waits for the enrolled machine, and destroys idempotently", async () => { + const test = await setup(); + const created = await test.provider.create(context()); + if (created.status !== "created") throw new Error("creation failed"); + const lifecycle = { + ...context(), + hostId: created.hostId, + resource: created.resource, + async checkpoint() {}, + }; + await test.provider.suspend?.(lifecycle); + await test.provider.suspend?.(lifecycle); + await test.provider.resume?.(lifecycle); + expect(test.api.power.mock.calls.map((call) => call[1])).toEqual([ + "shutdown", + "power_on", + ]); + expect(test.waitForConnection).toHaveBeenCalledTimes(2); + await test.provider.remove(lifecycle); + await test.provider.remove(lifecycle); + expect(test.api.destroy).toHaveBeenCalledOnce(); + await test.harness.lifecycle.dispose(); + }); + + it("refuses to destroy a droplet whose vendor ownership tag changed", async () => { + const test = await setup(); + test.api.get.mockResolvedValue({ ...droplet(), tags: [] }); + await expect( + test.provider.remove({ + ...context(), + hostId: "host-1", + resource: { + version: 1, + key: "creation-key", + dropletId: 42, + enrollmentId: "enrollment-1", + }, + }), + ).rejects.toThrow("ownership"); + expect(test.api.destroy).not.toHaveBeenCalled(); + await test.harness.lifecycle.dispose(); + }); +}); + +it("reconciles uncertain tag allocations without create or enrollment", async () => { + const test = await setup(); + const request = context(); + expect(await test.provider.experimental_reconcileCleanup(request)).toEqual({ + status: "removed", + }); + await test.bb.storage.kv.set(`allocation/${allocationName(request.key)}`, { + dropletId: null, + }); + expect( + await test.provider.experimental_reconcileCleanup(request), + ).toMatchObject({ status: "failed" }); + test.api.find.mockResolvedValue(droplet()); + expect(await test.provider.experimental_reconcileCleanup(request)).toEqual({ + status: "removed", + }); + expect(test.api.destroy).toHaveBeenCalledWith(42, request.signal); + test.api.find.mockResolvedValue(null); + expect(await test.provider.experimental_reconcileCleanup(request)).toEqual({ + status: "removed", + }); + expect(test.api.create).not.toHaveBeenCalled(); + expect(test.prepare).not.toHaveBeenCalled(); + expect(test.installerCommand).not.toHaveBeenCalled(); + expect(test.waitForConnection).not.toHaveBeenCalled(); + await test.harness.lifecycle.dispose(); +}); + +it.each([400, 401, 403, 404, 422])( + "settles definitive HTTP %s allocation rejection without reconciliation", + async (status) => { + const test = await setup(); + test.api.create.mockRejectedValueOnce(new VendorError(status)); + await expect(test.provider.create(context())).resolves.toMatchObject({ + status: "failed", + failure: "terminal", + allocation: "none", + }); + const finds = test.api.find.mock.calls.length; + await expect( + test.provider.experimental_reconcileCleanup(context()), + ).resolves.toEqual({ status: "removed" }); + await expect(test.provider.create(context())).resolves.toMatchObject({ + status: "failed", + allocation: "none", + }); + expect(test.api.find).toHaveBeenCalledTimes(finds); + expect(test.api.create).toHaveBeenCalledOnce(); + expect(test.api.destroy).not.toHaveBeenCalled(); + await test.harness.lifecycle.dispose(); + }, +); +it("runs durable scheduled sleep and wake through core and retries a busy sleep", async () => { + let time = Date.parse("2026-09-07T18:59:00Z"); + const test = await setup(() => time); + const result = await test.provider.create(context()); + if (result.status !== "created") throw new Error("creation failed"); + let phase: "active" | "suspended" = "active"; + test.harness.sdk.stub("hosts.get", async () => ({ + id: "host-1", + name: "Devbox", + status: "connected", + machineProviderId: "digitalocean", + machineProviderSelection: { inputs: {} }, + maxPermissionMode: "full", + lastSeenAt: time, + lastRejectedProtocolVersion: null, + createdAt: time, + updatedAt: time, + connectMachineId: null, + lifecycle: { + phase, + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, + })); + const suspend = vi.fn(async () => { + phase = "suspended"; + return { ok: true as const }; + }); + const resume = vi.fn(async () => { + phase = "active"; + return { ok: true as const }; + }); + test.harness.sdk.stub("hosts.suspend", suspend); + test.harness.sdk.stub("hosts.resume", resume); + await test.harness.behavior.callRpc("configure", { + hostId: "host-1", + config: { + idleMinutes: 5, + retention: 2, + schedule: { + weekdays: [1], + sleep: "19:00", + wake: "19:02", + timezone: "UTC", + }, + }, + }); + expect( + await test.provider.experimental_idleSuspendMs?.({ + hostId: "host-1", + resource: result.resource, + }), + ).toBe(300_000); + time += 60_000; + suspend.mockRejectedValueOnce(new Error("machine busy")); + await test.harness.behavior.runSchedule("devbox-schedules"); + expect(phase).toBe("active"); + time += 60_000; + await test.harness.behavior.runSchedule("devbox-schedules"); + expect(suspend).toHaveBeenCalledTimes(2); + expect(phase).toBe("suspended"); + await test.harness.behavior.runSchedule("devbox-schedules"); + expect(suspend).toHaveBeenCalledTimes(2); + time += 60_000; + await test.harness.behavior.runSchedule("devbox-schedules"); + expect(resume).toHaveBeenCalledOnce(); + expect(phase).toBe("active"); + expect(test.api.power).not.toHaveBeenCalled(); + await test.harness.lifecycle.dispose(); +}); + +async function scheduleSetup() { + let time = Date.parse("2026-09-07T18:59:00Z"); + const t = await setup(() => time); + await t.provider.create(context()); + let phase = "active"; + const get = vi.fn(async () => ({ + id: "host-1", + name: "Devbox", + status: "connected", + machineProviderId: "digitalocean", + machineProviderSelection: { inputs: {} }, + maxPermissionMode: "full", + lastSeenAt: time, + lastRejectedProtocolVersion: null, + createdAt: time, + updatedAt: time, + connectMachineId: null, + lifecycle: { + phase, + suspendedAt: null, + retireAt: null, + progress: null, + teardown: null, + }, + })); + t.harness.sdk.stub("hosts.get", get); + const suspend = vi.fn(async () => { + phase = "suspended"; + return { ok: true as const }; + }); + const resume = vi.fn(async () => { + phase = "active"; + return { ok: true as const }; + }); + t.harness.sdk.stub("hosts.suspend", suspend); + t.harness.sdk.stub("hosts.resume", resume); + await t.harness.behavior.callRpc("configure", { + hostId: "host-1", + config: { + schedule: { + weekdays: [1], + sleep: "19:00", + wake: "19:02", + timezone: "UTC", + }, + }, + }); + return { + ...t, + get, + suspend, + resume, + setTime: (x: number) => { + time = x; + }, + setPhase: (x: string) => { + phase = x; + }, + }; +} + +function gate() { + let release = () => {}; + const promise = new Promise((resolve) => { + release = resolve; + }); + return { promise, release }; +} + +it("keeps a scheduled wake pending while manual suspension is snapshotting", async () => { + const t = await scheduleSetup(); + const store = createDevboxStore(t.bb.storage.kv); + const cursor = (await store.get("host-1")).scheduleCursor; + t.setPhase("suspending"); + t.setTime(Date.parse("2026-09-07T19:02:00Z")); + await t.harness.behavior.runSchedule("devbox-schedules"); + expect((await store.get("host-1")).scheduleCursor).toBe(cursor); + expect(t.resume).not.toHaveBeenCalled(); + t.setPhase("suspended"); + t.setTime(Date.parse("2026-09-07T19:03:00Z")); + await t.harness.behavior.runSchedule("devbox-schedules"); + expect(t.resume).toHaveBeenCalledOnce(); + expect((await store.get("host-1")).scheduleCursor).toBe( + Date.parse("2026-09-07T19:02:00Z"), + ); + await t.harness.lifecycle.dispose(); +}); + +it("routes scheduled wake through core even when suspension is exposed as active", async () => { + const t = await scheduleSetup(); + const entered = gate(); + const finish = gate(); + t.resume.mockImplementationOnce(async () => { + entered.release(); + await finish.promise; + return { ok: true }; + }); + t.setTime(Date.parse("2026-09-07T19:02:00Z")); + const run = t.harness.behavior.runSchedule("devbox-schedules"); + await entered.promise; + expect( + (await createDevboxStore(t.bb.storage.kv).get("host-1")).scheduleCursor, + ).toBeLessThan(Date.parse("2026-09-07T19:02:00Z")); + finish.release(); + await run; + expect(t.resume).toHaveBeenCalledOnce(); + await t.harness.lifecycle.dispose(); +}); + +it("invalidates a selected schedule when disabled during machine lookup", async () => { + const t = await scheduleSetup(); + const entered = gate(); + const finish = gate(); + const original = t.get.getMockImplementation(); + if (!original) throw new Error("missing lookup"); + t.get.mockImplementationOnce(async () => { + entered.release(); + await finish.promise; + return original(); + }); + t.setTime(Date.parse("2026-09-07T19:00:00Z")); + const run = t.harness.behavior.runSchedule("devbox-schedules"); + await entered.promise; + t.setTime(Date.parse("2026-09-07T19:01:00Z")); + await t.harness.behavior.callRpc("configure", { + hostId: "host-1", + config: { schedule: null }, + }); + const saved = await createDevboxStore(t.bb.storage.kv).get("host-1"); + finish.release(); + await run; + expect(t.suspend).not.toHaveBeenCalled(); + expect(await createDevboxStore(t.bb.storage.kv).get("host-1")).toEqual(saved); + await t.harness.lifecycle.dispose(); +}); + +it("does not overwrite a replacement schedule cursor after an in-flight dispatch", async () => { + const t = await scheduleSetup(); + const entered = gate(); + const finish = gate(); + t.suspend.mockImplementationOnce(async () => { + entered.release(); + await finish.promise; + t.setPhase("suspended"); + return { ok: true }; + }); + t.setTime(Date.parse("2026-09-07T19:00:00Z")); + const run = t.harness.behavior.runSchedule("devbox-schedules"); + await entered.promise; + t.setTime(Date.parse("2026-09-07T19:01:00Z")); + await t.harness.behavior.callRpc("configure", { + hostId: "host-1", + config: { schedule: null }, + }); + const saved = await createDevboxStore(t.bb.storage.kv).get("host-1"); + finish.release(); + await run; + expect(await createDevboxStore(t.bb.storage.kv).get("host-1")).toEqual(saved); + await t.harness.lifecycle.dispose(); +}); + +it("coalesces ten concurrent status calls into one vendor inventory fetch", async () => { + const t = await scheduleSetup(); + const entered = gate(); + const finish = gate(); + const original = t.api.inventory.getMockImplementation(); + if (!original) throw new Error("missing inventory"); + t.api.inventory.mockImplementationOnce(async () => { + entered.release(); + await finish.promise; + return original(); + }); + const reads = Promise.all( + Array.from({ length: 10 }, () => + t.harness.behavior.callRpc("status", { hostId: "host-1" }), + ), + ); + await entered.promise; + finish.release(); + await reads; + expect(t.api.inventory).toHaveBeenCalledOnce(); + await t.harness.behavior.callRpc("status", { hostId: "host-1" }); + expect(t.api.inventory).toHaveBeenCalledOnce(); + t.setTime(Date.parse("2026-09-07T19:00:00Z")); + await t.harness.behavior.callRpc("status", { hostId: "host-1" }); + expect(t.api.inventory).toHaveBeenCalledTimes(2); + const created = await t.provider.create(context()); + if (created.status !== "created") throw new Error("creation failed"); + await t.provider.suspend?.({ + ...context(), + hostId: "host-1", + resource: created.resource, + checkpoint() {}, + }); + await t.harness.behavior.callRpc("status", { hostId: "host-1" }); + expect(t.api.inventory).toHaveBeenCalledTimes(3); + await t.harness.lifecycle.dispose(); +}); + +it("returns durable off-backup-failed in CLI and status when inventory remains unavailable", async () => { + const t = await scheduleSetup(); + const created = await t.provider.create(context()); + if (created.status !== "created") throw new Error("creation failed"); + t.suspend.mockImplementation(async () => { + await t.provider.suspend?.({ + ...context(), + hostId: "host-1", + resource: created.resource, + checkpoint() {}, + }); + return { ok: true }; + }); + t.api.snapshots.mockRejectedValue( + new Error("DigitalOcean API returned HTTP 503."), + ); + t.api.inventory.mockRejectedValue( + new Error("DigitalOcean API returned HTTP 503."), + ); + const result = await t.harness.runCli(["sleep", "host-1", "--json"]); + expect(result.exitCode).toBe(1); + expect(JSON.parse(result.stdout ?? "null")).toMatchObject({ + ok: true, + power: "off", + backupStatus: "off, backup failed", + snapshotId: null, + details: { + values: { + power: "off", + backupStatus: "off, backup failed", + cost: null, + inventoryError: expect.any(String), + }, + }, + }); + expect( + await t.harness.behavior.callRpc("status", { hostId: "host-1" }), + ).toMatchObject({ + values: { + power: "off", + backupStatus: "off, backup failed", + cost: null, + inventoryError: expect.any(String), + }, + }); + expect((await t.api.get())?.status).toBe("off"); + await t.harness.lifecycle.dispose(); +}); + +it("invalidates pending backup intent durably before an uncertain wake", async () => { + const t = await scheduleSetup(); + const created = await t.provider.create(context()); + if (created.status !== "created") throw new Error("creation failed"); + const lifecycle = { + ...context(), + hostId: "host-1", + resource: created.resource, + async checkpoint() {}, + }; + const original = t.api.snapshot.getMockImplementation(); + if (!original) throw new Error("missing snapshot"); + t.api.snapshot.mockImplementationOnce(async (id, name) => { + await original(id, name); + throw new Error("snapshot response lost"); + }); + await t.provider.suspend?.(lifecycle); + const store = createDevboxStore(t.bb.storage.kv); + expect((await store.get("host-1")).pendingSnapshot).not.toBeNull(); + const power = t.api.power.getMockImplementation(); + if (!power) throw new Error("missing power"); + t.api.power.mockImplementationOnce(async (id, action) => { + expect((await store.get("host-1")).pendingSnapshot).toBeNull(); + await power(id, action); + throw new Error("wake response lost"); + }); + await expect(t.provider.resume?.(lifecycle)).rejects.toThrow( + "wake response lost", + ); + await t.provider.suspend?.(lifecycle); + expect(t.api.snapshot).toHaveBeenCalledTimes(2); + expect((await store.get("host-1")).backupStatus).toBe("complete"); + await t.harness.lifecycle.dispose(); +}); diff --git a/plugins/machine-digitalocean/server.ts b/plugins/machine-digitalocean/server.ts new file mode 100644 index 0000000000..6230f99438 --- /dev/null +++ b/plugins/machine-digitalocean/server.ts @@ -0,0 +1,619 @@ +import { cacheVendorInventory } from "./inventory-cache.js"; +import { devboxRpc, hostInput } from "./rpc.js"; +import type { BbPluginApi } from "@get-bb/plugin-sdk"; +import { setTimeout } from "node:timers/promises"; +import { z } from "zod"; +import { cloudInit } from "./cloud-init.js"; +import { inputsSchema } from "./inputs.js"; +import { + BILLING, + PRICING, + configSchema, + createDevboxStore, + computeCost, + latestScheduledAction, + pruneSnapshots, + recordPower, + invalidatePendingSnapshot, + sleepWithBackup, +} from "./devbox.js"; +import { + allocationName, + createVendor, + VendorError, + type Droplet, + type Vendor, +} from "./vendor.js"; + +const resourceSchema = z + .object({ + version: z.literal(1), + key: z.string().min(1), + dropletId: z.number().int().positive(), + enrollmentId: z.string().min(1), + }) + .strict(); +const intentSchema = z + .object({ + dropletId: z.number().int().positive().nullable(), + rejected: z.boolean().default(false), + }) + .strict(); +const WAIT_MS = 600_000; +class AllocationError extends Error {} + +export function createDigitalOceanPlugin(deps: { + vendor: (token: string) => Vendor; + sleep: (signal: AbortSignal) => Promise; + now?: () => number; +}) { + return async (bb: BbPluginApi) => { + const now = deps.now ?? Date.now; + const store = createDevboxStore(bb.storage.kv, now); + const settings = bb.settings.define({ + snapshotRetention: { + type: "number", + default: 2, + label: "Snapshots to retain (1–100); snapshot storage bills per GB", + }, + DIGITALOCEAN_TOKEN: { + type: "string", + secret: true, + label: "DigitalOcean API token", + }, + }); + let client: { token: string; api: Vendor } | null = null; + async function vendor() { + const token = (await settings.get()).DIGITALOCEAN_TOKEN?.trim(); + if (!token) + throw new AllocationError( + "Configure the DIGITALOCEAN_TOKEN plugin setting.", + ); + if (client?.token !== token) + client = { token, api: cacheVendorInventory(deps.vendor(token), now) }; + return client.api; + } + async function owned( + api: Vendor, + id: number, + key: string, + signal: AbortSignal, + ) { + const droplet = await api.get(id, signal); + const name = allocationName(key); + if ( + droplet !== null && + (droplet.name !== name || !droplet.tags.includes(name)) + ) { + throw new AllocationError( + "DigitalOcean allocation ownership does not match.", + ); + } + return droplet; + } + async function active(api: Vendor, droplet: Droplet, signal: AbortSignal) { + while (droplet.status !== "active") { + if (droplet.status !== "new") + throw new AllocationError( + "DigitalOcean Droplet is not booting or active.", + ); + await deps.sleep(signal); + const next = await api.get(droplet.id, signal); + if (next === null) + throw new AllocationError( + "DigitalOcean Droplet disappeared during creation.", + ); + droplet = next; + } + } + async function machine(hostId: string) { + const host = await bb.sdk.hosts.get({ hostId }); + if (host.machineProviderId !== "digitalocean") + throw new Error("Select a DigitalOcean machine."); + let stored = await bb.storage.kv.get(`resource/${hostId}`); + if (stored === undefined) { + await bb.sdk.hosts.experimental_providerDetails({ hostId }); + stored = await bb.storage.kv.get(`resource/${hostId}`); + } + return { host, resource: resourceSchema.parse(stored) }; + } + async function details( + hostId: string, + resource: z.infer, + signal: AbortSignal, + ) { + await bb.storage.kv.set(`resource/${hostId}`, resource); + if ( + (await bb.storage.kv.get(`devbox/${hostId}`)) === undefined + ) { + await store.set(hostId, await store.get(hostId)); + } + const state = await store.get(hostId); + try { + const api = await vendor(); + const inventory = await api.inventory(resource.dropletId, signal); + const name = allocationName(resource.key); + if ( + inventory.droplet !== null && + (inventory.droplet.name !== name || + !inventory.droplet.tags.includes(name)) + ) + throw new AllocationError( + "DigitalOcean allocation ownership does not match.", + ); + if ( + inventory.droplet?.status === "active" || + inventory.droplet?.status === "off" + ) + recordPower(state, inventory.droplet.status, now()); + const cost = computeCost(inventory, state, hostId, now()); + const latest = state.snapshots.at(-1); + return { + summary: `${BILLING}. $${cost.monthlyEstimate.toFixed(2)}/month + account unassigned IPs $${cost.accountUnassignedIpMonthlyEstimate.toFixed(2)}/month. Snapshots: ${cost.snapshotGb.toFixed(2)} GB ($${cost.snapshotMonthlyEstimate.toFixed(2)}/month). Observed running ${cost.runningHoursObserved.toFixed(2)} h / off ${cost.offHoursObserved.toFixed(2)} h. Backup: ${state.backupStatus}${latest ? ` · ${latest.id}, ${latest.size_gigabytes} GB, ${latest.created_at}` : ""}.`, + values: { ...state, cost, inventoryError: null }, + }; + } catch { + return { + summary: `${BILLING}. Backup: ${state.backupStatus}. Inventory unavailable.`, + values: { + ...state, + cost: null, + inventoryError: "DigitalOcean inventory unavailable; retry later.", + }, + }; + } + } + const handlers = { + async machines() { + return (await bb.sdk.hosts.list()) + .filter( + (host) => + host.machineProviderId === "digitalocean" && + host.lifecycle.phase !== "destroyed", + ) + .map(({ id, name }) => ({ id, name })); + }, + async configuration({ hostId }: z.infer) { + await machine(hostId); + return (await store.get(hostId)).config; + }, + async status({ hostId }: z.infer) { + const { resource } = await machine(hostId); + return details(hostId, resource, AbortSignal.timeout(WAIT_MS)); + }, + async configure({ + hostId, + config, + }: { + hostId: string; + config: z.infer; + }) { + return store.exclusive(hostId, async () => { + await machine(hostId); + const state = await store.get(hostId); + state.config = config; + state.scheduleCursor = now(); + state.scheduleRevision += 1; + state.scheduleError = null; + await store.set(hostId, state); + return config; + }); + }, + async sleep({ hostId }: z.infer) { + const { resource } = await machine(hostId); + await bb.sdk.hosts.suspend({ hostId }); + const state = await store.get(hostId); + return { + ok: true as const, + power: state.power, + backupStatus: state.backupStatus, + backupError: state.backupError, + details: await details(hostId, resource, AbortSignal.timeout(30_000)), + snapshotId: state.snapshots.at(-1)?.id ?? null, + }; + }, + async wake({ hostId }: z.infer) { + await machine(hostId); + await bb.sdk.hosts.resume({ hostId }); + return { ok: true as const }; + }, + }; + bb.rpc.register(devboxRpc, handlers); + bb.cli.register({ + name: "digitalocean", + summary: `Manage long-lived dev boxes. ${BILLING}. ${PRICING}`, + commands: [ + { + name: "status", + summary: "Live inventory, backups, schedule and estimated cost", + usage: "bb digitalocean status [--json]", + }, + { + name: "configure", + summary: + "Set idleMinutes (null disables), retention and weekday schedule with explicit timezone", + usage: "bb digitalocean configure [--json]", + }, + { + name: "snapshot-now", + summary: "Quiesce, gracefully shut down, snapshot and remain off", + usage: "bb digitalocean snapshot-now [--json]", + }, + { + name: "sleep", + summary: "Sleep with backup through core", + usage: "bb digitalocean sleep [--json]", + }, + { + name: "wake", + summary: "Resume through core", + usage: "bb digitalocean wake [--json]", + }, + { + name: "cost", + summary: "Show estimated live costs", + usage: "bb digitalocean cost [--json]", + }, + ], + async run(argv) { + const [command, hostId, config, ...extra] = argv.filter( + (arg) => arg !== "--json", + ); + const input = hostInput.parse({ hostId }); + if (extra.length || (command !== "configure" && config !== undefined)) + throw new Error("Unexpected arguments"); + let result; + let exitCode = 0; + if (command === "configure") + result = await handlers.configure({ + ...input, + config: configSchema.parse(JSON.parse(config ?? "null")), + }); + else if (command === "status" || command === "cost") + result = await handlers.status(input); + else if (command === "sleep" || command === "snapshot-now") { + const slept = await handlers.sleep(input); + exitCode = slept.backupStatus === "off, backup failed" ? 1 : 0; + result = slept; + } else if (command === "wake") result = await handlers.wake(input); + else + throw new Error( + "Use status, configure, snapshot-now, sleep, wake or cost.", + ); + return { exitCode, stdout: JSON.stringify(result, null, 2) }; + }, + }); + const scheduleClaims = new Set(); + bb.background.schedule("devbox-schedules", "* * * * *", async () => { + for (const hostId of await store.hosts()) { + const state = await store.get(hostId); + if (state.config.schedule === null) continue; + const scheduled = latestScheduledAction( + state.config.schedule, + state.scheduleCursor, + now(), + ); + if (!scheduled || scheduleClaims.has(hostId)) continue; + scheduleClaims.add(hostId); + try { + const { host } = await machine(hostId); + const desired = scheduled.action === "sleep" ? "suspended" : "active"; + if ( + host.lifecycle.phase !== "active" && + host.lifecycle.phase !== "suspended" + ) + continue; + const claim = await store.exclusive(hostId, async () => { + const current = await store.get(hostId); + if ( + current.scheduleRevision !== state.scheduleRevision || + current.scheduleCursor >= scheduled.at + ) + return null; + return { + operation: + scheduled.action === "wake" + ? bb.sdk.hosts.resume({ hostId }) + : host.lifecycle.phase === desired + ? Promise.resolve() + : bb.sdk.hosts.suspend({ hostId }), + }; + }); + if (!claim) continue; + await claim.operation; + const established = await bb.sdk.hosts.get({ hostId }); + if (established.lifecycle.phase !== desired) continue; + await store.exclusive(hostId, async () => { + const current = await store.get(hostId); + if (current.scheduleRevision !== state.scheduleRevision) return; + current.scheduleCursor = Math.max( + current.scheduleCursor, + scheduled.at, + ); + current.scheduleError = null; + await store.set(hostId, current); + }); + } catch { + await store.exclusive(hostId, async () => { + const current = await store.get(hostId); + if (current.scheduleRevision !== state.scheduleRevision) return; + current.scheduleError = + "Scheduled action failed or machine is busy; retry next minute until superseded by the next scheduled action."; + await store.set(hostId, current); + }); + } finally { + scheduleClaims.delete(hostId); + } + } + }); + bb.experimental_machines.register({ + id: "digitalocean", + displayName: "DigitalOcean", + icon: "./digitalocean-logo.svg", + inputs: inputsSchema, + policy: { + idleSuspendMs: null, + retire: { after: "never" }, + removeRetryMs: 30_000, + }, + async experimental_idleSuspendMs({ hostId }) { + const { idleMinutes } = (await store.get(hostId)).config; + return idleMinutes === null ? null : idleMinutes * 60_000; + }, + async experimental_details({ hostId, resource, signal }) { + return details(hostId, resourceSchema.parse(resource), signal); + }, + async availability() { + return (await settings.get()).DIGITALOCEAN_TOKEN?.trim() + ? { status: "available" } + : { + status: "setup-required", + message: "Configure the DIGITALOCEAN_TOKEN plugin setting.", + }; + }, + async create(context) { + try { + const api = await vendor(); + const initialConfig = configSchema.parse({ + idleMinutes: inputsSchema.parse(context.inputs).idleMinutes, + retention: (await settings.get()).snapshotRetention, + }); + const name = allocationName(context.key); + const intentKey = `allocation/${name}`; + const stored = await bb.storage.kv.get(intentKey); + const intent = + stored === undefined ? null : intentSchema.parse(stored); + if (intent?.rejected) + return { + status: "failed", + failure: "terminal", + allocation: "none", + message: + "DigitalOcean rejected this allocation; correct configuration and use a new creation key.", + }; + const signal = AbortSignal.any([ + context.signal, + AbortSignal.timeout(WAIT_MS), + ]); + signal.throwIfAborted(); + const enrollment = await bb.experimental_machines.enrollments.prepare( + { key: context.key }, + ); + let droplet = intent?.dropletId + ? await owned(api, intent.dropletId, context.key, signal) + : await api.find(name, signal); + if (droplet === null && intent !== null) { + const reconcile = AbortSignal.any([ + signal, + AbortSignal.timeout(30_000), + ]); + try { + while (droplet === null) { + await deps.sleep(reconcile); + droplet = await api.find(name, reconcile); + } + } catch { + context.signal.throwIfAborted(); + throw new AllocationError( + "DigitalOcean allocation is unresolved after an earlier create submission. Reconcile the Droplet with its allocation tag before retrying; no second create was submitted.", + ); + } + } + if (droplet === null) { + if (enrollment.state !== "pending") + throw new AllocationError( + "Enrolled DigitalOcean machine has no Droplet; restore or remove the existing machine.", + ); + const installer = await bb.experimental_machines.installerCommand( + enrollment.bootstrap, + ); + const userData = cloudInit(installer); + signal.throwIfAborted(); + await bb.storage.kv.set(intentKey, { dropletId: null }); + context.report.step("Creating the DigitalOcean Droplet…"); + try { + droplet = await api.create( + { name, ...inputsSchema.parse(context.inputs), userData }, + signal, + ); + } catch (error) { + if ( + error instanceof VendorError && + [400, 401, 403, 404, 422].includes(error.status) + ) { + await bb.storage.kv.set(intentKey, { + dropletId: null, + rejected: true, + }); + return { + status: "failed", + failure: "terminal", + allocation: "none", + message: error.message, + }; + } + throw error; + } + } + const resource = { + version: 1, + key: context.key, + dropletId: droplet.id, + enrollmentId: enrollment.id, + }; + await context.checkpoint(resource); + await bb.storage.kv.set(`resource/${enrollment.hostId}`, resource); + if ( + (await bb.storage.kv.get( + `devbox/${enrollment.hostId}`, + )) === undefined + ) { + const state = await store.get(enrollment.hostId); + state.config = initialConfig; + await store.set(enrollment.hostId, state); + } + await bb.storage.kv.set(intentKey, { dropletId: droplet.id }); + signal.throwIfAborted(); + await active(api, droplet, signal); + context.report.step("Waiting for the machine to connect…"); + await bb.experimental_machines.enrollments.waitForConnection({ + enrollmentId: enrollment.id, + timeoutMs: WAIT_MS, + signal, + }); + return { + status: "created", + hostId: enrollment.hostId, + resource, + }; + } catch (error) { + context.signal.throwIfAborted(); + return { + status: "failed", + failure: + error instanceof VendorError && + [401, 403, 422].includes(error.status) + ? "terminal" + : "transient", + message: + error instanceof AllocationError || error instanceof VendorError + ? error.message + : "DigitalOcean provisioning failed. Retry to reconcile the existing allocation.", + }; + } + }, + async experimental_reconcileCleanup(context) { + const name = allocationName(context.key); + const stored = await bb.storage.kv.get(`allocation/${name}`); + if (stored === undefined) return { status: "removed" }; + const intent = intentSchema.parse(stored); + if (intent.rejected) return { status: "removed" }; + const api = await vendor(); + const droplet = + intent.dropletId === null + ? await api.find(name, context.signal) + : await owned(api, intent.dropletId, context.key, context.signal); + if (droplet === null && intent.dropletId === null) + return { + status: "failed", + message: + "DigitalOcean allocation intent is unresolved; retry tag reconciliation.", + }; + if (droplet !== null) { + await bb.storage.kv.set(`allocation/${name}`, { + dropletId: droplet.id, + }); + await api.destroy(droplet.id, context.signal); + } + return { status: "removed" }; + }, + async suspend(context) { + return store.exclusive(context.hostId, async () => { + const resource = resourceSchema.parse(context.resource); + const api = await vendor(); + const droplet = await owned( + api, + resource.dropletId, + resource.key, + context.signal, + ); + if (droplet === null) + throw new AllocationError("DigitalOcean Droplet no longer exists."); + context.report.step("Graceful shutdown, then snapshot…"); + const state = await sleepWithBackup({ + hostId: context.hostId, + dropletId: droplet.id, + api, + store, + signal: context.signal, + now, + }); + context.report.step(state.backupStatus); + context.checkpoint(resource); + return { resource }; + }); + }, + async resume(context) { + return store.exclusive(context.hostId, async () => { + const resource = resourceSchema.parse(context.resource); + const api = await vendor(); + const droplet = await owned( + api, + resource.dropletId, + resource.key, + context.signal, + ); + if (droplet === null) + throw new AllocationError("DigitalOcean Droplet no longer exists."); + const state = await store.get(context.hostId); + invalidatePendingSnapshot(state); + await store.set(context.hostId, state); + if (droplet.status !== "active") + await api.power(droplet.id, "power_on", context.signal); + recordPower(state, "active", now()); + await store.set(context.hostId, state); + const { hostId } = + await bb.experimental_machines.enrollments.waitForConnection({ + enrollmentId: resource.enrollmentId, + timeoutMs: WAIT_MS, + signal: context.signal, + }); + if (hostId !== context.hostId) + throw new AllocationError( + "DigitalOcean enrollment returned a different machine identity.", + ); + return { resource }; + }); + }, + async remove(context) { + return store.exclusive(context.hostId, async () => { + const resource = resourceSchema.parse(context.resource); + const api = await vendor(); + const droplet = await owned( + api, + resource.dropletId, + resource.key, + context.signal, + ); + if (droplet !== null) await api.destroy(droplet.id, context.signal); + await pruneSnapshots({ + hostId: context.hostId, + dropletId: resource.dropletId, + api, + store, + signal: context.signal, + keep: 0, + }); + await store.delete(context.hostId); + await bb.storage.kv.delete(`resource/${context.hostId}`); + return { status: "removed" as const }; + }); + }, + }); + }; +} + +export default createDigitalOceanPlugin({ + vendor: createVendor, + sleep: (signal) => setTimeout(3000, undefined, { signal }), +}); diff --git a/plugins/machine-digitalocean/settings.tsx b/plugins/machine-digitalocean/settings.tsx new file mode 100644 index 0000000000..99b048ab20 --- /dev/null +++ b/plugins/machine-digitalocean/settings.tsx @@ -0,0 +1,244 @@ +import { useEffect, useState } from "react"; +import { useRpc } from "@get-bb/plugin-sdk/app"; +import { Input } from "@bb/shared-ui/input"; +import { Button } from "@bb/shared-ui/button"; +import type { devboxRpc } from "./rpc.js"; +import { configSchema, type DevboxConfig } from "./devbox.js"; + +export function DevboxSettings() { + const rpc = useRpc(); + const [machines, setMachines] = useState<{ id: string; name: string }[]>([]); + const [hostId, setHostId] = useState(""); + const [config, setConfig] = useState(null); + const [message, setMessage] = useState(""); + const [busy, setBusy] = useState(false); + useEffect(() => { + let active = true; + void rpc + .call("machines") + .then((value) => { + if (active) setMachines(value); + }) + .catch(() => { + if (active) setMessage("Could not load machines"); + }); + return () => { + active = false; + }; + }, [rpc]); + useEffect(() => { + let active = true; + if (hostId) + void rpc + .call("configuration", { hostId }) + .then((value) => { + if (active) setConfig(value); + }) + .catch(() => { + if (active) setMessage("Could not load configuration"); + }); + return () => { + active = false; + }; + }, [rpc, hostId]); + async function act(action: "configure" | "sleep" | "wake" | "status") { + if (!config) return; + setBusy(true); + try { + if (action === "configure") { + await rpc.call("configure", { + hostId, + config: configSchema.parse(config), + }); + setMessage("Saved"); + } else if (action === "status") { + const result = await rpc.call("status", { hostId }); + setMessage(result.summary); + } else { + await rpc.call(action, { hostId }); + const result = await rpc.call("status", { hostId }); + setMessage(result.summary); + } + } catch (error) { + setMessage(error instanceof Error ? error.message : "Operation failed"); + } finally { + setBusy(false); + } + } + return ( +
+

+ Powered-off droplets still bill; snapshot storage bills per GB.{" "} + + Droplet pricing + {" "} + ·{" "} + + Snapshot pricing + +

+ + {config ? ( + <> + + + + {config.schedule ? ( + <> +
+ {["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"].map( + (day, index) => ( + + ), + )} +
+ {(["sleep", "wake", "timezone"] as const).map((key) => ( + + ))} +

+ The always-on BB server catches up the latest missed action + within eight days. Busy sleep retries each minute until + superseded. Skipped DST times do not run; repeated times may run + twice. +

+ + ) : null} +
+ + + + +
+ + ) : null} +
+        {message}
+      
+
+ ); +} diff --git a/plugins/machine-digitalocean/skills/digitalocean-devbox/SKILL.md b/plugins/machine-digitalocean/skills/digitalocean-devbox/SKILL.md new file mode 100644 index 0000000000..3df5b12edf --- /dev/null +++ b/plugins/machine-digitalocean/skills/digitalocean-devbox/SKILL.md @@ -0,0 +1,72 @@ +--- +name: digitalocean-devbox +description: "Configure or operate a long-lived DigitalOcean BB dev box: idle stop, weekday sleep/wake, snapshots, immediate wake, inventory and estimated cost." +--- + +Use `bb machine list --json` to select an existing DigitalOcean host. Run from +an always-on local thread/server. The DigitalOcean plugin must be configured +with its secret DIGITALOCEAN_TOKEN setting; never print the token. + +- `bb digitalocean status --json` reads backups, configuration and live costs. +- `bb digitalocean configure '' --json` replaces settings. +- `bb digitalocean snapshot-now --json` quiesces, gracefully shuts down, + confirms off, snapshots and stays off. Requires active machine and idle threads + with no open terminals. `sleep` is equivalent. +- `bb digitalocean wake --json` resumes through core. Core also wakes + automatically on dispatch. +- `bb digitalocean cost --json` and `bb machine show --json` + show live vendor inventory with estimates. + +Configuration example: + +```json +{ + "idleMinutes": 60, + "retention": 2, + "schedule": { + "weekdays": [1, 2, 3, 4, 5], + "sleep": "19:00", + "wake": "08:00", + "timezone": "America/Los_Angeles" + } +} +``` + +`idleMinutes`: null disables (default); otherwise 1–43200 minutes. Empty machines +also idle-stop. Retirement stays never. `retention`: 1–100, default 2; the global +`snapshotRetention` setting sets new-machine defaults. `schedule`: null disables; +otherwise Sunday=0, explicit IANA timezone and HH:mm times. Configuration saves +reset the cursor and invalidate undispatched runs. In-flight runs cannot +overwrite a replacement configuration cursor. Wake waits for a pending sleep +and is a no-op if already active. Omitted fields get defaults, so send the complete desired state. + +The durable minute schedule catches up only the latest action within eight days. +Busy sleep retries next minute until superseded. Spring DST missing times skip; +autumn repeated times may run twice. All power operations use core, not direct +vendor actions. SDK equivalents: `bb.sdk.hosts.suspend/resume`, +`bb.sdk.hosts.experimental_providerDetails`, and the plugin RPC contract methods +`configure`, `configuration`, `machines`, `status`, `sleep`, `wake`. + +Powered-off droplets still bill; snapshot storage bills per GB. Link +[Droplet pricing](https://docs.digitalocean.com/products/droplets/details/pricing/) +and [snapshot pricing](https://docs.digitalocean.com/products/snapshots/details/pricing/). +Snapshot cost uses actual stored GB × $0.06/month. Account-wide unassigned IPs +are shown separately at $5/month. These are estimates, excluding tax, bandwidth, +volumes and credits. Do not describe sleep as compute cost savings. + +After sleep verify off and a snapshot ID/size. If status is `off, backup failed`, +keep the machine off and preserve prior backups; report it clearly. Wake then +retry sleep to create a fresh backup for the new shutdown generation. A retry +without an intervening wake may reconcile the original uncertain submission. +Sleep JSON preserves saved power/backup status even if optional inventory fails; +`details.values.cost` is then null with `inventoryError`. Status/UI retain the +same durable backup result. Inventory is coalesced and cached for 30 seconds, +with invalidation after mutations. Removal deletes the Droplet and its plugin-owned +snapshots only; verify cleanup through status/vendor inventory and exact 404s. +Never delete user snapshots or unrelated resources. + +Create boxes in Settings → Machines or with `bb machine create --provider +digitalocean --inputs '{}' --json`; no project is required. SDK: `bb.sdk.hosts.create` +with `machineProviderId: "digitalocean", projectId: null, inputs: {}`. Enrolled +boxes appear as machine sections in the composer picker. There is no DigitalOcean +new-machine/project-checkout shortcut row. diff --git a/plugins/machine-digitalocean/snapshot.ts b/plugins/machine-digitalocean/snapshot.ts new file mode 100644 index 0000000000..2c9f24a0f3 --- /dev/null +++ b/plugins/machine-digitalocean/snapshot.ts @@ -0,0 +1,8 @@ +import { z } from "zod"; +export const snapshotSchema = z.object({ + id: z.coerce.string(), + name: z.string(), + size_gigabytes: z.number().nonnegative(), + created_at: z.string().datetime(), + resource_id: z.coerce.string(), +}); diff --git a/plugins/machine-digitalocean/tsconfig.json b/plugins/machine-digitalocean/tsconfig.json new file mode 100644 index 0000000000..d1d7d3ad70 --- /dev/null +++ b/plugins/machine-digitalocean/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "strict": true, + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "esModuleInterop": true, + "noEmit": true, + "skipLibCheck": true, + "types": ["node"], + "paths": { + "@get-bb/plugin-sdk": [ + "../../packages/plugin-sdk/bundled-types/bb-plugin-sdk.d.ts" + ], + "@get-bb/plugin-sdk/testing": [ + "../../packages/plugin-sdk/bundled-types/bb-plugin-sdk-testing.d.ts" + ] + }, + "jsx": "react-jsx" + }, + "include": ["*.ts", "*.tsx"] +} diff --git a/plugins/machine-digitalocean/vendor.test.ts b/plugins/machine-digitalocean/vendor.test.ts new file mode 100644 index 0000000000..0f6297215c --- /dev/null +++ b/plugins/machine-digitalocean/vendor.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it, vi } from "vitest"; +import { allocationName, createVendor, type VendorFetch } from "./vendor.js"; + +const name = allocationName("key"); +const droplet = { id: 42, name, tags: [name], status: "active" }; +const signal = () => new AbortController().signal; +const response = (value: object, status = 200) => + new Response(JSON.stringify(value), { status }); + +describe("DigitalOcean REST adapter", () => { + it("uses documented create fields and consumes the response despite caller cancellation", async () => { + const controller = new AbortController(); + const request = vi.fn(async () => { + controller.abort(); + return response({ droplet }, 202); + }); + const api = createVendor("token-secret", request); + await expect( + api.create( + { + name, + region: "nyc3", + size: "s-2vcpu-4gb", + userData: "cloud-init-secret", + }, + controller.signal, + ), + ).resolves.toEqual(droplet); + expect(request).toHaveBeenCalledWith( + "https://api.digitalocean.com/v2/droplets", + expect.objectContaining({ + method: "POST", + headers: { + Authorization: "Bearer token-secret", + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + region: "nyc3", + size: "s-2vcpu-4gb", + image: "ubuntu-24-04-x64", + tags: [name], + user_data: "cloud-init-secret", + }), + }), + ); + const requestSignal = request.mock.calls[0]?.[1]?.signal; + expect(requestSignal?.aborted).toBe(false); + }); + + it("filters by tag and refuses duplicate allocation matches", async () => { + const request = vi + .fn() + .mockResolvedValue( + response({ droplets: [droplet, { ...droplet, id: 43 }] }), + ); + await expect( + createVendor("secret", request).find(name, signal()), + ).rejects.toThrow("Multiple"); + expect(request.mock.calls[0]?.[0]).toBe( + `https://api.digitalocean.com/v2/droplets?tag_name=${name}&per_page=2`, + ); + }); + + it("polls action completion and reports vendor action failure", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + response({ action: { id: 9, status: "in-progress" } }, 201), + ) + .mockResolvedValueOnce( + response({ action: { id: 9, status: "completed" } }), + ) + .mockResolvedValueOnce( + response({ droplet: { ...droplet, status: "off" } }), + ) + .mockResolvedValueOnce( + response({ action: { id: 10, status: "errored" } }, 201), + ); + const api = createVendor("secret", request); + const pending = api.power(42, "shutdown", signal()); + await pending; + expect(request.mock.calls[1]?.[0]).toBe( + "https://api.digitalocean.com/v2/droplets/42/actions/9", + ); + await expect(api.power(42, "power_on", signal())).rejects.toThrow( + "action failed", + ); + }); + + it.each([ + { type: "shutdown", stale: "active", desired: "off" }, + { type: "power_on", stale: "off", desired: "active" }, + ] as const)( + "waits for $desired after the $type action completes", + async ({ type, stale, desired }) => { + const observed: string[] = []; + const request = vi.fn(async (_url, options) => { + if (options?.method === "POST") + return response({ action: { id: 9, status: "completed" } }); + const status = observed.length === 0 ? stale : desired; + observed.push(status); + return response({ droplet: { ...droplet, status } }); + }); + await createVendor("secret", request).power(42, type, signal()); + expect(observed).toEqual([stale, desired]); + expect(request.mock.calls.slice(1).map(([url]) => url)).toEqual([ + "https://api.digitalocean.com/v2/droplets/42", + "https://api.digitalocean.com/v2/droplets/42", + ]); + }, + ); + + it("cancels stale-state polling after action completion", async () => { + const controller = new AbortController(); + const request = vi.fn(async (_url, options) => { + if (options?.method === "POST") + return response({ action: { id: 9, status: "completed" } }); + controller.abort(); + return response({ droplet }); + }); + await expect( + createVendor("secret", request).power(42, "shutdown", controller.signal), + ).rejects.toThrow(); + expect(request).toHaveBeenCalledTimes(2); + }); + + it("aborts an in-progress power action before the next vendor poll", async () => { + const controller = new AbortController(); + const request = vi.fn(async () => { + controller.abort(); + return response({ action: { id: 9, status: "in-progress" } }, 201); + }); + await expect( + createVendor("secret", request).power(42, "shutdown", controller.signal), + ).rejects.toThrow(); + expect(request).toHaveBeenCalledOnce(); + }); + + it("does not echo vendor bodies containing credentials and treats missing deletion as success", async () => { + const request = vi + .fn() + .mockResolvedValueOnce( + response({ message: "token-secret cloud-init-secret" }, 401), + ) + .mockResolvedValueOnce(response({}, 404)); + const api = createVendor("token-secret", request); + await expect( + api.create( + { + name, + region: "nyc3", + size: "s-2vcpu-4gb", + userData: "cloud-init-secret", + }, + signal(), + ), + ).rejects.toThrow(/^DigitalOcean API returned HTTP 401\.$/); + await expect(api.destroy(42, signal())).resolves.toBeUndefined(); + }); +}); + +it("shares account inventory across concurrent hosts, expires after 30 seconds and invalidates every mutation", async () => { + let time = 0; + let status = "active"; + const request = vi.fn(async (url, options) => { + const path = new URL(String(url)).pathname; + if (options?.method === "DELETE") + return new Response(null, { status: 204 }); + if (path.endsWith("/actions")) { + const body = JSON.parse(String(options?.body)); + if (body.type === "shutdown") status = "off"; + if (body.type === "power_on") status = "active"; + return response({ action: { id: 1, status: "completed" } }); + } + if (path === "/v2/sizes") + return response({ + sizes: [{ slug: "small", price_hourly: 0.006, price_monthly: 4 }], + }); + if (path === "/v2/snapshots") return response({ snapshots: [] }); + if (path === "/v2/reserved_ips") return response({ reserved_ips: [] }); + return response({ + droplet: { + ...droplet, + status, + created_at: "2026-09-07T00:00:00Z", + size_slug: "small", + }, + }); + }); + const api = createVendor("secret", request, () => time); + const read = () => api.inventory(42, signal()); + const count = (path: string) => + request.mock.calls.filter(([url]) => new URL(String(url)).pathname === path) + .length; + await Promise.all( + Array.from({ length: 10 }, (_, i) => api.inventory(42 + i, signal())), + ); + for (const path of ["/v2/sizes", "/v2/snapshots", "/v2/reserved_ips"]) + expect(count(path)).toBe(1); + await read(); + expect(count("/v2/sizes")).toBe(1); + time = 30_001; + await read(); + expect(count("/v2/sizes")).toBe(2); + const mutations = [ + () => api.snapshot(42, "backup", signal()), + () => api.deleteSnapshot("backup", signal()), + () => api.power(42, "shutdown", signal()), + () => api.destroy(42, signal()), + () => + api.create( + { name, region: "nyc3", size: "small", userData: "" }, + signal(), + ), + ]; + for (const [index, mutate] of mutations.entries()) { + await mutate(); + await read(); + expect(count("/v2/sizes")).toBe(3 + index); + } + await createVendor("other-token", request, () => time).inventory( + 42, + signal(), + ); + expect(count("/v2/sizes")).toBe(8); +}); diff --git a/plugins/machine-digitalocean/vendor.ts b/plugins/machine-digitalocean/vendor.ts new file mode 100644 index 0000000000..47558e8905 --- /dev/null +++ b/plugins/machine-digitalocean/vendor.ts @@ -0,0 +1,251 @@ +import { createReadCache } from "./inventory-cache.js"; +import { snapshotSchema } from "./snapshot.js"; +import { createHash } from "node:crypto"; +import { setTimeout } from "node:timers/promises"; +import { z } from "zod"; + +const dropletSchema = z.object({ + id: z.number().int().positive(), + name: z.string(), + tags: z.array(z.string()), + status: z.enum(["new", "active", "off", "archive"]), +}); +const inventoryDropletSchema = dropletSchema.extend({ + created_at: z.string().datetime(), + size_slug: z.string(), +}); +const sizeSchema = z.object({ + slug: z.string(), + price_hourly: z.number().nonnegative(), + price_monthly: z.number().nonnegative(), +}); +const ipSchema = z.object({ + ip: z.string(), + droplet: z.object({ id: z.number() }).nullable(), +}); +export type Snapshot = z.infer; +const actionSchema = z.object({ + id: z.number().int().positive(), + status: z.enum(["in-progress", "completed", "errored"]), +}); +export type Droplet = z.infer; +export type VendorFetch = typeof fetch; + +export function allocationName(key: string): string { + return `bb-${createHash("sha256").update(key).digest("hex").slice(0, 48)}`; +} + +export class VendorError extends Error { + constructor(readonly status: number) { + super(`DigitalOcean API returned HTTP ${status}.`); + } +} + +export function createVendor( + token: string, + requestFetch: VendorFetch = fetch, + now: () => number = Date.now, +) { + const ratesCache = createReadCache[]>(now); + const snapshotsCache = createReadCache(now); + const ipsCache = createReadCache[]>(now); + function invalidate() { + ratesCache.clear(); + snapshotsCache.clear(); + ipsCache.clear(); + } + async function request( + method: string, + path: string, + signal: AbortSignal, + body?: object, + ) { + if (method !== "GET") invalidate(); + const response = await requestFetch( + `https://api.digitalocean.com/v2${path}`, + { + method, + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + signal: AbortSignal.any([signal, AbortSignal.timeout(30_000)]), + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }, + ).finally(() => { + if (method !== "GET") invalidate(); + }); + if (response.status === 404 && (method === "GET" || method === "DELETE")) + return null; + if (!response.ok) throw new VendorError(response.status); + if (response.status === 204) return null; + const value: unknown = await response.json(); + return value; + } + + async function action( + id: number, + body: { type: string; name?: string }, + signal: AbortSignal, + ) { + const deadline = AbortSignal.any([signal, AbortSignal.timeout(3_600_000)]); + let current = z + .object({ action: actionSchema }) + .parse( + await request("POST", `/droplets/${id}/actions`, deadline, body), + ).action; + while (current.status === "in-progress") { + await setTimeout(3000, undefined, { signal: deadline }); + current = z + .object({ action: actionSchema }) + .parse( + await request( + "GET", + `/droplets/${id}/actions/${current.id}`, + deadline, + ), + ).action; + } + if (current.status !== "completed") + throw new Error(`DigitalOcean ${body.type} action failed.`); + } + async function pages( + path: string, + key: string, + schema: z.ZodType, + signal: AbortSignal, + ): Promise { + const results: T[] = []; + for (let page = 1; ; page++) { + const value = z + .record(z.string(), z.unknown()) + .parse( + await request( + "GET", + `${path}${path.includes("?") ? "&" : "?"}per_page=200&page=${page}`, + signal, + ), + ); + const items = z.array(schema).parse(value[key]); + results.push(...items); + const links = z + .object({ pages: z.object({ next: z.string().optional() }).optional() }) + .optional() + .parse(value.links); + if (!links?.pages?.next) return results; + } + } + function readSnapshots(signal: AbortSignal) { + return snapshotsCache.get("account", signal, (shared) => + pages( + "/snapshots?resource_type=droplet", + "snapshots", + snapshotSchema, + shared, + ), + ); + } + return { + snapshots: readSnapshots, + async snapshot(id: number, name: string, signal: AbortSignal) { + try { + await action(id, { type: "snapshot", name }, signal); + } finally { + invalidate(); + } + }, + async deleteSnapshot(id: string, signal: AbortSignal) { + await request("DELETE", `/snapshots/${encodeURIComponent(id)}`, signal); + }, + async inventory(id: number, signal: AbortSignal) { + const [raw, sizes, snapshots, reservedIps] = await Promise.all([ + request("GET", `/droplets/${id}`, signal), + ratesCache.get("account", signal, (shared) => + pages("/sizes", "sizes", sizeSchema, shared), + ), + readSnapshots(signal), + ipsCache.get("account", signal, (shared) => + pages("/reserved_ips", "reserved_ips", ipSchema, shared), + ), + ]); + return { + droplet: + raw === null + ? null + : z.object({ droplet: inventoryDropletSchema }).parse(raw).droplet, + sizes, + snapshots, + reservedIps, + }; + }, + async find(name: string, signal: AbortSignal): Promise { + const value = await request( + "GET", + `/droplets?tag_name=${encodeURIComponent(name)}&per_page=2`, + signal, + ); + const result = z + .object({ droplets: z.array(dropletSchema) }) + .parse(value); + if (result.droplets.length > 1) + throw new Error( + "Multiple DigitalOcean Droplets match the allocation key; reconcile them before retrying.", + ); + const droplet = result.droplets[0]; + if (droplet === undefined) return null; + if (droplet.name !== name || !droplet.tags.includes(name)) + throw new Error("DigitalOcean allocation ownership does not match."); + return droplet; + }, + async get(id: number, signal: AbortSignal): Promise { + const result = await request("GET", `/droplets/${id}`, signal); + return result === null + ? null + : z.object({ droplet: dropletSchema }).parse(result).droplet; + }, + async create( + input: { name: string; region: string; size: string; userData: string }, + signal: AbortSignal, + ): Promise { + signal.throwIfAborted(); + const result = await request( + "POST", + "/droplets", + AbortSignal.timeout(30_000), + { + name: input.name, + region: input.region, + size: input.size, + image: "ubuntu-24-04-x64", + tags: [input.name], + user_data: input.userData, + }, + ); + return z.object({ droplet: dropletSchema }).parse(result).droplet; + }, + async power( + id: number, + type: "shutdown" | "power_on", + signal: AbortSignal, + ): Promise { + const deadline = AbortSignal.any([signal, AbortSignal.timeout(300_000)]); + await action(id, { type }, deadline); + const desiredStatus = type === "power_on" ? "active" : "off"; + while (true) { + const value = await request("GET", `/droplets/${id}`, deadline); + if (value === null) + throw new Error( + "DigitalOcean Droplet disappeared during power action.", + ); + const { droplet } = z.object({ droplet: dropletSchema }).parse(value); + if (droplet.status === desiredStatus) return; + await setTimeout(3000, undefined, { signal: deadline }); + } + }, + async destroy(id: number, signal: AbortSignal): Promise { + await request("DELETE", `/droplets/${id}`, signal); + }, + }; +} + +export type Vendor = ReturnType; diff --git a/plugins/machine-digitalocean/vitest.config.ts b/plugins/machine-digitalocean/vitest.config.ts new file mode 100644 index 0000000000..84aedf5023 --- /dev/null +++ b/plugins/machine-digitalocean/vitest.config.ts @@ -0,0 +1,16 @@ +import { fileURLToPath } from "node:url"; +import { + defineWorkspaceTestConfig, + sharedWorkerProjects, +} from "../../vitest.shared.js"; + +export default defineWorkspaceTestConfig({ + test: { + silent: "passed-only", + projects: sharedWorkerProjects({ + pkgDir: fileURLToPath(new URL(".", import.meta.url)), + name: "bb-plugin-machine-digitalocean", + include: ["**/*.test.ts", "**/*.test.tsx"], + }), + }, +}); diff --git a/plugins/provider-claude-code/src/bridge/provider-maintenance.test.ts b/plugins/provider-claude-code/src/bridge/provider-maintenance.test.ts index 058b552154..801be7dfce 100644 --- a/plugins/provider-claude-code/src/bridge/provider-maintenance.test.ts +++ b/plugins/provider-claude-code/src/bridge/provider-maintenance.test.ts @@ -1,5 +1,8 @@ -import { describe, expect, it } from "vitest"; -import { __testing } from "./provider-maintenance.js"; +import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it, vi } from "vitest"; +import { __testing, getClaudeProviderHealth } from "./provider-maintenance.js"; function missingInstallationStatus() { return { @@ -81,3 +84,19 @@ describe("Claude Code provider maintenance", () => { ); }); }); + +it("recognizes environment API-key authentication without a credential file", async () => { + const directory = await mkdtemp(join(tmpdir(), "claude-env-health-")); + try { + const executable = join(directory, "claude"); + await writeFile(executable, "#!/bin/sh\necho 2.1.0\n", { mode: 0o700 }); + vi.stubEnv("BB_CLAUDE_CODE_EXECUTABLE", executable); + vi.stubEnv("ANTHROPIC_API_KEY", "synthetic-environment-key"); + await expect(getClaudeProviderHealth()).resolves.toMatchObject({ + health: { status: "ready" }, + }); + } finally { + vi.unstubAllEnvs(); + await rm(directory, { recursive: true, force: true }); + } +}); diff --git a/plugins/provider-claude-code/src/bridge/provider-maintenance.ts b/plugins/provider-claude-code/src/bridge/provider-maintenance.ts index f6683ccde0..e81b19624d 100644 --- a/plugins/provider-claude-code/src/bridge/provider-maintenance.ts +++ b/plugins/provider-claude-code/src/bridge/provider-maintenance.ts @@ -345,6 +345,8 @@ export async function getClaudeProviderHealth(): Promise { return healthResult("not_installed"); } const version = await readCliVersion(command); + if (process.env.ANTHROPIC_API_KEY?.trim()) + return healthResult("ready", { installedVersion: version }); try { const [credentials, email] = await Promise.all([ readCredentials(), diff --git a/plugins/provider-codex/src/bridge/bridge.session-signature.test.ts b/plugins/provider-codex/src/bridge/bridge.session-signature.test.ts index eecd69dec7..0da8f76f25 100644 --- a/plugins/provider-codex/src/bridge/bridge.session-signature.test.ts +++ b/plugins/provider-codex/src/bridge/bridge.session-signature.test.ts @@ -109,3 +109,38 @@ it("keeps an auto-reviewed session when only escalation intent changes", async ( harness.messages.filter((message) => message.method === "session/replaced"), ).toEqual([]); }, 30_000); + +it.each<{ label: string; next: Record }>([ + { label: "rotation", next: { BB_MACHINE_VALUE: "rotated" } }, + { label: "removal", next: {} }, +])( + "rebuilds the session after environment $label", + async ({ next }) => { + harness.sendRequest(1, "thread/start", { + threadId: THREAD_ID, + cwd: workspaceDir, + instructionMode: "append", + options: { ...sessionOptions, envVars: { BB_MACHINE_VALUE: "original" } }, + }); + const started = await harness.waitForResponse(1); + const providerThreadId = (started.result as { providerThreadId: string }) + .providerThreadId; + + harness.sendRequest(2, "turn/start", { + threadId: THREAD_ID, + providerThreadId, + clientRequestId: "creq_signature4", + input: [{ type: "text", text: "say hello", mentions: [] }], + options: { ...sessionOptions, envVars: next }, + }); + const turn = await harness.waitForResponse(2); + + expect(turn.error).toBeUndefined(); + expect( + harness.messages.filter( + (message) => message.method === "session/replaced", + ), + ).toHaveLength(1); + }, + 30_000, +); diff --git a/plugins/provider-codex/src/bridge/bridge.ts b/plugins/provider-codex/src/bridge/bridge.ts index 381a10e6aa..605e92e70b 100644 --- a/plugins/provider-codex/src/bridge/bridge.ts +++ b/plugins/provider-codex/src/bridge/bridge.ts @@ -404,6 +404,7 @@ function describeCodexLaunchError(error: unknown): string { interface CodexSessionConstruction { cwd: string; + envVars: Record; instructionMode: "append" | "replace"; dynamicTools: DynamicTool[] | undefined; } @@ -512,8 +513,6 @@ function constructionSignature( sessionOptions: CodexSessionOptions, ): string { const permissionSettings = toCodexThreadPermissionSettings(sessionOptions); - const poolBaseUrl = sessionOptions.envVars?.[CODEX_POOL_BASE_URL_ENV]; - const poolToken = sessionOptions.envVars?.[CODEX_POOL_AUTH_TOKEN_ENV]; return JSON.stringify({ cwd, reasoningLevel: sessionOptions.reasoningLevel ?? null, @@ -522,13 +521,15 @@ function constructionSignature( approvalPolicy: permissionSettings.approvalPolicy, approvalsReviewer: permissionSettings.approvalsReviewer, sandbox: permissionSettings.sandbox, - poolRoute: - poolBaseUrl === undefined || poolToken === undefined - ? null - : { - baseUrl: poolBaseUrl, - tokenHash: createHash("sha256").update(poolToken).digest("hex"), - }, + environmentHash: createHash("sha256") + .update( + JSON.stringify( + Object.entries(sessionOptions.envVars ?? {}).sort(([a], [b]) => + a.localeCompare(b), + ), + ), + ) + .digest("hex"), }); } @@ -944,6 +945,7 @@ async function constructThreadSession( translator, construction: { cwd: args.cwd, + envVars: decoded.sessionOptions.envVars ?? {}, instructionMode: args.instructionMode, dynamicTools: args.dynamicTools, }, @@ -1358,26 +1360,30 @@ async function requireLiveSessionForTurn( } const decoded = decodeCodexOptions(params.options); - const signature = constructionSignature( - session.construction.cwd, - decoded.sessionOptions, - ); + const options = { + ...params.options, + envVars: decoded.sessionOptions.envVars ?? session.construction.envVars, + }; + const signature = constructionSignature(session.construction.cwd, { + ...decoded.sessionOptions, + envVars: options.envVars, + }); if (session.connection === null || session.connection.exited) { session = await rebuildThreadSession( session, - params.options, + options, "codex app-server exited; the session was restored from its rollout.", ); } else if (session.rebuildBeforeNextTurnReason !== null) { session = await rebuildThreadSession( session, - params.options, + options, session.rebuildBeforeNextTurnReason, ); } else if (signature !== session.constructionSignature) { session = await rebuildThreadSession( session, - params.options, + options, "Execution settings changed; the codex session was rebuilt to apply them.", ); } diff --git a/plugins/provider-codex/src/bridge/provider-maintenance.test.ts b/plugins/provider-codex/src/bridge/provider-maintenance.test.ts index 0571c1a992..03a591817a 100644 --- a/plugins/provider-codex/src/bridge/provider-maintenance.test.ts +++ b/plugins/provider-codex/src/bridge/provider-maintenance.test.ts @@ -156,6 +156,17 @@ describe("Codex credential health and usage", () => { ); }); + it("recognizes environment API-key authentication without an on-disk login", async () => { + vi.stubEnv("OPENAI_API_KEY", "synthetic-environment-key"); + await expect(getCodexProviderHealth()).resolves.toMatchObject({ + health: { status: "ready" }, + }); + vi.stubEnv("OPENAI_API_KEY", ""); + await expect(getCodexProviderHealth()).resolves.toMatchObject({ + health: { status: "unauthenticated" }, + }); + }); + it("reports unauthenticated when auth.json is missing", async () => { await expect(getCodexProviderHealth()).resolves.toEqual({ supported: true, diff --git a/plugins/provider-codex/src/bridge/provider-maintenance.ts b/plugins/provider-codex/src/bridge/provider-maintenance.ts index 271906100f..fb9a6e8e55 100644 --- a/plugins/provider-codex/src/bridge/provider-maintenance.ts +++ b/plugins/provider-codex/src/bridge/provider-maintenance.ts @@ -210,6 +210,8 @@ export async function getCodexProviderHealth(): Promise { ) { return healthResult("unsupported_version", { installedVersion: version }); } + if (process.env.OPENAI_API_KEY?.trim()) + return healthResult("ready", { installedVersion: version }); try { const credentials = await readCredentials(); if (credentials === null) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7febf728c..51cff16b5b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1918,6 +1918,22 @@ importers: specifier: npm:typescript@^7.0.2 version: typescript@7.0.2 + packages/machine-ssh: + dependencies: + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^22.0.0 + version: 22.19.10 + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + vitest: + specifier: ^4.1.1 + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.4.0))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + packages/mobile-bridge: dependencies: zod: @@ -3439,6 +3455,46 @@ importers: specifier: ^4.1.1 version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.4.0))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + plugins/machine-digitalocean: + dependencies: + '@bb/shared-ui': + specifier: workspace:* + version: link:../../packages/shared-ui + zod: + specifier: 4.3.6 + version: 4.3.6 + devDependencies: + '@bb/db': + specifier: workspace:* + version: link:../../packages/db + '@get-bb/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk + '@testing-library/react': + specifier: ^16.3.2 + version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.3(@types/react@19.2.13))(@types/react@19.2.13)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@types/node': + specifier: ^22.0.0 + version: 22.19.10 + '@types/react': + specifier: ^19.0.0 + version: 19.2.13 + jsdom: + specifier: ^29.0.1 + version: 29.0.1(@noble/hashes@2.4.0) + react: + specifier: ^19.0.0 + version: 19.2.4 + react-dom: + specifier: ^19.0.0 + version: 19.2.4(react@19.2.4) + typescript: + specifier: npm:@typescript/typescript6@^6.0.2 + version: '@typescript/typescript6@6.0.2' + vitest: + specifier: ^4.1.1 + version: 4.1.1(@opentelemetry/api@1.9.1)(@types/node@22.19.10)(jsdom@29.0.1(@noble/hashes@2.4.0))(msw@2.12.14(@types/node@22.19.10)(@typescript/typescript6@6.0.2))(vite@8.0.12(@types/node@22.19.10)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.50.0)(tsx@4.23.1)(yaml@2.9.0)) + plugins/memory: dependencies: '@bb/shared-ui': @@ -4262,12 +4318,12 @@ importers: specifier: 4.3.6 version: 4.3.6 devDependencies: - '@get-bb/plugin-sdk': - specifier: workspace:* - version: link:../../packages/plugin-sdk '@bb/tsconfig': specifier: workspace:* version: link:../../packages/tsconfig + '@get-bb/plugin-sdk': + specifier: workspace:* + version: link:../../packages/plugin-sdk '@types/node': specifier: ^22.0.0 version: 22.19.10 @@ -5315,11 +5371,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.19.12': resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} @@ -5921,7 +5977,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@57.0.16': resolution: {integrity: sha512-+HyMY2nAS6QBJb0nSeMz92p11bZ1AtWSRnhWnklznN07IRoQirisnKq2vr18Ayjb/fnb5F/um+n6FRhF0fZm1Q==} @@ -19977,9 +20033,7 @@ snapshots: metro-runtime: 0.84.5 transitivePeerDependencies: - '@babel/core' - - bufferutil - supports-color - - utf-8-validate '@react-native/normalize-colors@0.79.6': {} diff --git a/tests/integration/fake/environments/readiness-interactions.test.ts b/tests/integration/fake/environments/readiness-interactions.test.ts new file mode 100644 index 0000000000..4591e2ada9 --- /dev/null +++ b/tests/integration/fake/environments/readiness-interactions.test.ts @@ -0,0 +1,271 @@ +import { createHarness as createDaemonHarness } from "../../../../apps/host-daemon/test/command/dispatch-helpers.js"; +import { RuntimeManager } from "../../../../apps/host-daemon/src/runtime-manager.js"; +import { it, expect, vi } from "vitest"; +import { eq } from "drizzle-orm"; +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { execFileSync } from "node:child_process"; +import { + hosts, + createEnvironment, + environmentHookOperations, + environmentSetupOutcomes, +} from "@bb/db"; +import { withTestHarness } from "../../../../apps/server/test/helpers/test-app.js"; +import { + seedHostSession, + seedProjectWithSource, +} from "../../../../apps/server/test/helpers/seed.js"; +import { + registerHostRpcResponder, + type HostRpcHandlerResult, +} from "../../../../apps/server/test/helpers/host-rpc.js"; +import { ensureHostReady } from "../../../../apps/server/src/services/machines/readiness.js"; +import { runEnvironmentHook } from "../../../../apps/server/src/services/environments/environment-hooks.js"; +import { runEnvironmentHook as daemonHook } from "../../../../apps/host-daemon/src/command-handlers/environment-hook.js"; +import { inspectReadiness } from "../../../../apps/host-daemon/src/command-handlers/readiness.js"; +import { + updateMachineEnvironment, + resolveUserMachineEnvironment, +} from "../../../../apps/server/src/services/machines/environment-settings.js"; +import { setPluginAgentContributions } from "../../../../apps/server/src/services/plugins/plugin-agent-contributions.js"; + +it.each(["personal", "legacy", "legacy-completed", "machine-auth"])( + "proves %s integration behavior with a migrated database", + async (scenario) => { + await withTestHarness(async (h) => { + const { host, session } = seedHostSession(h.deps); + const { project } = seedProjectWithSource(h.deps, { hostId: host.id }); + h.db + .update(hosts) + .set({ machineProviderId: "manual", resource: {} }) + .where(eq(hosts.id, host.id)) + .run(); + const path = join(h.deps.config.dataDir, scenario); + await mkdir(path); + if (scenario.startsWith("legacy")) { + execFileSync("git", ["init", "-q", path]); + execFileSync("git", [ + "-C", + path, + "-c", + "user.name=Review", + "-c", + "user.email=review@example.test", + "commit", + "--allow-empty", + "-qm", + "initial", + ]); + } + createEnvironment(h.db, h.hub, { + projectId: project.id, + hostId: host.id, + path, + status: "ready", + providerOwnsPath: scenario !== "machine-auth", + environmentProvider: null, + }); + await mkdir(join(h.deps.config.dataDir, "review-bin")); + await writeFile( + join(h.deps.config.dataDir, "review-bin", "gh"), + "#!/bin/sh\nexit 1\n", + { mode: 0o700 }, + ); + vi.stubEnv( + "PATH", + join(h.deps.config.dataDir, "review-bin") + ":" + process.env.PATH, + ); + const runtimeManager = new RuntimeManager({ + shellEnv: { PATH: process.env.PATH ?? "" }, + }); + let actualHookRuns = 0; + let healthCommand: unknown; + setPluginAgentContributions({ + listSkillRootContributions: () => [], + listAgentTools: () => [], + listInstructionContributions: () => [], + findAgentTool: () => undefined, + invokeAgentTool: async () => ({ success: false, contentItems: [] }), + resolveMention: async () => ({ ok: false, error: "unused" }), + resolveProviderEnvHealth: async () => null, + resolveProviderEnv: async () => ({ entries: [] }), + }); + const responder = registerHostRpcResponder(h, { + hostId: host.id, + sessionId: session.id, + handle: async ({ command }): Promise => { + if (command.type === "provider.installation.status") + return { + ok: true, + result: { + executableName: "codex", + executablePath: "/bin/codex", + installed: true, + installSource: "npmGlobal", + currentVersion: "1.0.0", + latestVersion: "1.0.0", + minimumSupportedVersion: "1.0.0", + npmPackageName: "codex", + npmGlobalPackageVersion: null, + installAction: null, + needsUpdate: false, + versionUnsupported: false, + }, + }; + if (command.type === "provider.health") { + healthCommand = command; + return { + ok: true, + result: { + supported: true, + health: { + status: + scenario === "machine-auth" && + !command.contributedEnv?.some( + (entry) => + entry.name === "OPENAI_API_KEY" && + entry.value === "SYNTHETIC_MACHINE_KEY", + ) + ? "unauthenticated" + : "ready", + statusMessage: null, + accountEmail: null, + planLabel: null, + installedVersion: "1.0.0", + minimumSupportedVersion: "1.0.0", + canInstall: false, + canUpdate: false, + loginCommand: "login", + }, + }, + }; + } + if (command.type === "workspace.readiness.inspect") { + try { + return { ok: true, result: await inspectReadiness(command.path) }; + } catch { + return { + ok: false, + errorCode: "not_git", + errorMessage: "Not a git repository", + }; + } + } + if (command.type === "environment.hook.run") { + actualHookRuns++; + await daemonHook(command, { + ...createDaemonHarness().dispatchOptions({ + dataDir: h.deps.config.dataDir, + }), + runtimeManager, + }); + return { ok: true, result: {} }; + } + if (command.type === "environment.hook.cancel") + return { ok: true, result: { status: "terminated" as const } }; + throw Error("Unexpected " + command.type); + }, + }); + try { + if (scenario.startsWith("legacy")) { + h.db + .insert(environmentHookOperations) + .values({ + id: "old-create-hook", + operationId: "old-operation", + hostId: host.id, + path, + kind: "setup", + startedAt: 1, + finishedAt: 2, + error: null, + }) + .run(); + if (scenario === "legacy-completed") { + await runEnvironmentHook(h.deps, { + id: "old-create-hook", + hostId: host.id, + path, + kind: "setup", + resumeOnly: false, + report: { step() {}, log() {} }, + signal: new AbortController().signal, + }); + expect( + h.db.select().from(environmentSetupOutcomes).get()?.state, + ).toBe("passed"); + } + } + if (scenario === "personal") { + await writeFile(join(path, ".bb-env-setup.sh"), "exit 0\n"); + await runEnvironmentHook(h.deps, { + id: "personal-create", + hostId: host.id, + path, + kind: "setup", + resumeOnly: false, + report: { step() {}, log() {} }, + signal: new AbortController().signal, + }); + expect(actualHookRuns).toBe(1); + expect( + h.db.select().from(environmentSetupOutcomes).get()?.state, + ).toBe("passed"); + } + if (scenario === "machine-auth") { + await updateMachineEnvironment( + h.db, + h.deps.config.dataDir, + "OPENAI_API_KEY", + { + name: "OPENAI_API_KEY", + value: "SYNTHETIC_MACHINE_KEY", + secret: true, + note: null, + }, + ); + } + const result = await ensureHostReady(h.deps, { + hostId: host.id, + projectId: project.id, + providerId: "codex", + threadId: null, + path, + }); + expect(result).toMatchObject({ status: "ready" }); + if (scenario === "personal") { + await writeFile(join(path, ".bb-env-setup.sh"), "exit 1\n"); + expect( + await ensureHostReady(h.deps, { + hostId: host.id, + projectId: project.id, + providerId: "codex", + threadId: null, + path, + }), + ).toMatchObject({ status: "blocked", code: "setup_stale" }); + expect(actualHookRuns).toBe(1); + } + if (scenario.startsWith("legacy")) expect(actualHookRuns).toBe(0); + if (scenario === "machine-auth") { + expect(healthCommand).toHaveProperty("contributedEnv"); + expect( + await resolveUserMachineEnvironment(h.db, h.deps.config.dataDir), + ).toContainEqual( + expect.objectContaining({ + name: "OPENAI_API_KEY", + value: "SYNTHETIC_MACHINE_KEY", + }), + ); + } + expect(h.db.$client.pragma("foreign_key_check")).toEqual([]); + } finally { + await runtimeManager.shutdownAll(); + responder.unregister(); + setPluginAgentContributions(undefined); + vi.unstubAllEnvs(); + } + }); + }, +); diff --git a/tests/integration/fake/smoke/machine-environment-setup.test.ts b/tests/integration/fake/smoke/machine-environment-setup.test.ts new file mode 100644 index 0000000000..33c0abf13c --- /dev/null +++ b/tests/integration/fake/smoke/machine-environment-setup.test.ts @@ -0,0 +1,88 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { machineEnrollments, updateHost } from "@bb/db"; +import { expect, it } from "vitest"; +import { updateMachineEnvironment } from "../../../../apps/server/src/services/machines/environment-settings.js"; +import { + createProjectFixture, + createReadyHostThread, +} from "../../helpers/fixtures.js"; +import { withHarness } from "../../helpers/harness.js"; +import { createTestGitRepo } from "../../helpers/seed.js"; + +it.each([true, false])( + "passes GH_TOKEN through the core hook runner only for machine hosts: %s", + (machine) => + withHarness( + { builtinPlugins: ["environment-git-worktree"] }, + async (harness) => { + const provider = harness.server.providerRegistry.get("fake"); + if (!provider) throw new Error("Missing scripted provider"); + harness.server.providerRegistry.register({ + ...provider, + info: { + ...provider.info, + id: "fake-installed", + maintenance: { ...provider.info.maintenance, installation: true }, + }, + }); + if (machine) + updateHost(harness.db, harness.server.hub, harness.hostId, { + machineProviderId: "manual", + }); + if (machine) + harness.db + .insert(machineEnrollments) + .values({ + id: "setup-machine", + owner: "manual", + key: "setup-machine", + hostId: harness.hostId, + state: "enrolled", + createdAt: Date.now(), + updatedAt: Date.now(), + }) + .run(); + await updateMachineEnvironment( + harness.db, + harness.server.config.dataDir, + "GH_TOKEN", + { + name: "GH_TOKEN", + value: "setup-fixture-token", + secret: true, + note: null, + }, + ); + const sourcePath = await createTestGitRepo({ + repoDir: path.join( + path.dirname(harness.repoDir), + "machine-setup-project", + ), + files: [ + { + relativePath: ".bb-env-setup.sh", + content: machine + ? 'set -eu\ntest "$GH_TOKEN" = setup-fixture-token\nprintf setup-token-present > setup-result\n' + : 'set -eu\ntest -z "${GH_TOKEN:-}"\nprintf setup-token-absent > setup-result\n', + }, + ], + }); + const project = await createProjectFixture(harness, { + name: "Machine setup environment", + path: sourcePath, + }); + const { environment } = await createReadyHostThread(harness, { + providerId: "fake-installed", + projectId: project.id, + workspace: { type: "managed-worktree" }, + timeoutMs: 30_000, + }); + expect(environment.path).toBeTruthy(); + if (!environment.path) throw new Error("Missing setup workspace"); + expect( + await readFile(path.join(environment.path, "setup-result"), "utf8"), + ).toBe(machine ? "setup-token-present" : "setup-token-absent"); + }, + ), +); diff --git a/tests/integration/helpers/harness.ts b/tests/integration/helpers/harness.ts index 804bbb8d9a..c6809458da 100644 --- a/tests/integration/helpers/harness.ts +++ b/tests/integration/helpers/harness.ts @@ -382,7 +382,6 @@ async function startHarnessDaemon( const identity = await loadHostIdentity({ dataDir }); const hostKey = await server.machineAuth.issueDaemonHostKey({ hostId: identity.hostId, - hostType: "persistent", }); await persistHostId({ dataDir, hostId: identity.hostId }); const daemonApp = await createHostDaemonApp({ @@ -390,7 +389,6 @@ async function startHarnessDaemon( hostKey, hostId: identity.hostId, hostName: identity.hostName, - hostType: "persistent", instanceId: randomUUID(), localApiConfig: null, logger: testLogger, diff --git a/tests/scripted-echo-provider/src/provider-bridge.ts b/tests/scripted-echo-provider/src/provider-bridge.ts index 1c1d33e69e..a80c8459db 100644 --- a/tests/scripted-echo-provider/src/provider-bridge.ts +++ b/tests/scripted-echo-provider/src/provider-bridge.ts @@ -86,6 +86,8 @@ export const scriptedEchoOptionsSchema = z recoveryThreadIdHint: z.string().min(1).optional(), approvalEnforcedBy: z.enum(["runtime", "provider"]).optional(), identifyProcess: z.boolean().optional(), + textDeltaChunkSize: z.number().int().positive().optional(), + stderrChunksOnTurn: z.array(z.string()).optional(), failStopForThreadIds: z.array(z.string().min(1)).optional(), emitIdentityOnSigterm: z.boolean().optional(), }) @@ -395,6 +397,26 @@ function clearActiveTurn(session: Session): void { session.activeTurn = null; } +function splitTextDeltas( + text: string, + size: number | undefined, + key: { providerItemId: string }, + providerTurnId: string, +): ThreadDelta[] { + if (size === undefined) return []; + const deltas: ThreadDelta[] = []; + for (let offset = 0; offset < text.length; offset += size) { + deltas.push({ + kind: "item.textDelta", + key, + channel: "agentMessage", + text: text.slice(offset, offset + size), + providerTurnId, + }); + } + return deltas; +} + function completeTurn( session: Session, status: "completed" | "interrupted" | "failed", @@ -405,6 +427,9 @@ function completeTurn( return; } clearActiveTurn(session); + session.options.stderrChunksOnTurn?.forEach((chunk, index) => { + setTimeout(() => process.stderr.write(chunk), index * 10); + }); const responseText = session.options.identifyProcess === true ? `pid:${process.pid}:${text}` @@ -420,6 +445,12 @@ function completeTurn( item: { type: "agentMessage", text: "" }, providerTurnId: turn.providerTurnId, }, + ...splitTextDeltas( + responseText, + session.options.textDeltaChunkSize, + key, + turn.providerTurnId, + ), { kind: "item.close", key, diff --git a/turbo.json b/turbo.json index 36d3df92c4..1d8adf3cc2 100644 --- a/turbo.json +++ b/turbo.json @@ -519,6 +519,7 @@ // bundling step — so the SDK dist must exist before the suite runs. "@bb/server#test": { "dependsOn": [ + "@bb/agent-runtime#generate:test-bridges", "//#ensure-native-modules", "@get-bb/plugin-sdk#build", "topo" @@ -817,6 +818,9 @@ "bb-plugin-environment-personal-workspace#typecheck": { "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] }, + "bb-plugin-machine-digitalocean#typecheck": { + "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] + }, "bb-plugin-connect#typecheck": { "dependsOn": ["@get-bb/plugin-sdk#build:types", "topo"] },