Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
a3d07e6
Rebase machine providers onto the environment consolidation
ymichael Sep 10, 2026
421b617
Port composition tests onto the consolidated engine
ymichael Sep 10, 2026
8e6ffa1
Fix machine suspension recovery, queued follow-ups, and cleanup
ymichael Sep 11, 2026
0de8aa4
Fix CLI inspection and cancellation of creating machines
ymichael Sep 11, 2026
fec4fcf
Guard live thread startup and verify shared machine ownership
ymichael Sep 11, 2026
2de45f8
Recover interrupted startup and finish asynchronous thread deletion
ymichael Sep 11, 2026
be8a1cb
Fix machine provider CI contracts and defer folder browser loading
ymichael Sep 11, 2026
e778f5e
Tighten machine plugin contracts and fix live access readiness
ymichael Sep 11, 2026
8c9efd1
Integrate Modal runtime builds and wait for CLI enrollment readiness
ymichael Sep 11, 2026
3c8f8f7
Align demo machine access with availability response contract
ymichael Sep 11, 2026
6b28c5d
Add durable resuming machine phase
ymichael Sep 11, 2026
95b1846
Scope provider icons and extract Modal lifecycle backend
ymichael Sep 11, 2026
aeddbb0
Include nested plugin sources in artifact test fixtures
ymichael Sep 11, 2026
f845f7b
Remove provider descriptions from environment picker
ymichael Sep 11, 2026
f7aa522
Add bubblewrap to Modal sandbox image
ymichael Sep 11, 2026
2c20f4f
Restore direct folder browser loading and use unknown-folder fallback
ymichael Sep 11, 2026
e6e66dc
Stop the composer focus loop that dismissed open pickers
ymichael Sep 11, 2026
625c14d
Retry checkout recovery after pending thread start settles
ymichael Sep 11, 2026
b9993af
Require every host call site to declare its machine scope
ymichael Sep 11, 2026
53ca1d4
Scope CLI and SDK host enumeration to persistent machines
ymichael Sep 11, 2026
92d25be
Preserve sandbox identity and align project machine counts
ymichael Sep 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 1 addition & 1 deletion .bb/skills/verify-bb/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ were excluded. See the [audit](../MAINTENANCE.md) and
| [Panels, files, terminals, splits, and embedded browser](workspace-panels.md) | 15 | 12 passed, 3 partial/blocked |
| [Settings, keyboard, appearance controls, and usage](settings.md) | 13 | 6 passed, 7 partial/blocked |
| [Skills, plugins, marketplaces, and plugin development](extensions.md) | 13 | 11 passed, 2 partial/blocked |
| [Machines, daemon lifecycle, and updates](hosts-updates.md) | 8 | 2 passed, 6 partial/blocked |
| [Machines, daemon lifecycle, and updates](hosts-updates.md) | 9 | 2 passed, 7 partial/blocked |
| [Agent interfaces, route compatibility, and error contracts](compatibility-api.md) | 8 | 3 passed, 5 partial/blocked |
| [Responsive layouts, accessibility, and performance](responsive-accessibility.md) | 8 | 8 partial/blocked |

Expand Down
3 changes: 3 additions & 0 deletions .bb/skills/verify-bb/features/hosts-updates.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,11 @@ command’s `--help` before mutation. Use fresh browser snapshots for controls.
## Source

- `apps/app/src/views/MachineSettingsView.tsx`
- `apps/app/src/components/promptbox/banner/ThreadMachineStatus.tsx`
- `apps/cli/src/commands/machine.ts`
- `apps/cli/src/commands/updates.ts`
- `apps/host-daemon/src/server-connection.ts`
- `apps/server/src/services/machines/provider-orchestration.ts`

## Feature recipes

Expand All @@ -26,6 +28,7 @@ command’s `--help` before mutation. Use fresh browser snapshots for controls.
| Pair and enroll | Create machine join-code and redeem on the disposable host; attempt expired/reused code. | Exactly one intended host enrolls; invalid or consumed codes do not enroll another. |
| Permission ceiling | Change the disposable machine ceiling and request a more permissive thread. | Host ceiling is enforced across UI, CLI, and runtime rather than merely hidden in the picker. |
| Disconnect and reconnect | Stop only the disposable daemon, observe unavailable host, restart it, and retry a targeted read. | Status and routing recover to the same host; offline operations do not route to a different machine. |
| Suspend and resume | Suspend a disposable provider-managed machine, send a thread follow-up to wake it, then repeat with the prompt banner's Resume action. | Every wake exposes a durable `resuming` lifecycle phase; the prompt banner says “Machine is resuming…” until the machine becomes active, and queued work dispatches once. |
| Protocol mismatch and automatic update | Use the documented QA setup with a deliberately older disposable daemon; inspect rejected protocol and retry-update. | Mismatch initiates the expected update or actionable failure; incompatible payloads are not accepted in a reconnect loop. |
| Provider CLI installation | Inspect machine provider-cli status; install/update a chosen provider on the disposable host. | Version and health refresh on that host; failure does not claim installation. |
| Updates status and apply | Compare updates status and Settings → Updates; apply only available fixture-host updates. | Per-host/per-provider outcomes are reported; absent updates produce a truthful no-op. |
Expand Down
170 changes: 170 additions & 0 deletions apps/app/.ladle/machine-story-fixtures.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import type {
ServerAccessStatus,
SystemMachineProvider,
} from "@bb/server-contract";
import { machineServerAccessBlockedReason } from "../src/components/machines/machine-server-access";
import type { MachineAccessState } from "../src/components/settings/MachineAccessSettings";
import modalLogoUrl from "../../../plugins/environment-modal-sandbox/modal-logo.svg";

const noop = () => {};
const noopAsync = async () => {};

export const CONNECT_UNPAIRED: ServerAccessStatus = {
providers: [
{
id: "connect",
displayName: "bb connect",
description: "Use a private getbb.app address.",
pluginId: "connect",
availability: {
status: "setup-required",
message: "Pair with bb connect",
},
},
{
id: "direct",
displayName: "Manual",
description: "Use your own domain or network address.",
pluginId: null,
availability: null,
},
],
defaultProviderId: "connect",
effectiveUrl: null,
urlSource: null,
};

export const CONNECT_PAIRED: ServerAccessStatus = {
...CONNECT_UNPAIRED,
providers: CONNECT_UNPAIRED.providers.map((provider) =>
provider.id === "connect"
? {
...provider,
availability: {
status: "available",
serverUrl: "https://bb.example.com",
},
}
: provider,
),
};

export const CONNECT_PAIRED_WITHOUT_URL: ServerAccessStatus = {
...CONNECT_PAIRED,
providers: CONNECT_PAIRED.providers.map((provider) =>
provider.id === "connect"
? { ...provider, availability: { status: "available" } }
: provider,
),
};

export const CONNECT_UNAVAILABLE: ServerAccessStatus = {
...CONNECT_UNPAIRED,
providers: CONNECT_UNPAIRED.providers.map((provider) =>
provider.id === "connect"
? {
...provider,
availability: {
status: "unavailable",
message: "Credential revoked",
},
}
: provider,
),
};

export const MANUAL_WITHOUT_URL: ServerAccessStatus = {
...CONNECT_UNPAIRED,
defaultProviderId: "direct",
};

export const MANUAL_WITH_URL: ServerAccessStatus = {
...CONNECT_UNPAIRED,
defaultProviderId: "direct",
effectiveUrl: "https://bb.example.com",
urlSource: "setting",
};

export const METHOD_NOT_INSTALLED: ServerAccessStatus = {
...CONNECT_UNPAIRED,
providers: [CONNECT_UNPAIRED.providers[1]!],
defaultProviderId: "tailscale",
};

export function machineAccessState(
access: ServerAccessStatus,
overrides: Partial<MachineAccessState> = {},
): MachineAccessState {
const selected = overrides.selected ?? access.defaultProviderId ?? "connect";
return {
access,
disabled: false,
draft: null,
error: null,
effective: access.providers.find((provider) => provider.id === selected),
configurationMessage: machineServerAccessBlockedReason(access, selected),
saving: false,
selected,
value: access.urlSource === "setting" ? (access.effectiveUrl ?? "") : "",
editDraft: noop,
selectProvider: noop,
commitUrl: noopAsync,
...overrides,
};
}

export function machineProvider(
overrides: Partial<SystemMachineProvider> &
Pick<SystemMachineProvider, "id" | "displayName">,
): SystemMachineProvider {
return {
description: "Run a machine for development.",
icon: "Terminal",
logoUrl: null,
pluginId: `plugin-${overrides.id}`,
inputs: null,
acceptsEmptyInputs: true,
supportsSuspend: false,
...overrides,
};
}

export const MODAL_MACHINE_PROVIDER = machineProvider({
id: "modal-sandbox",
displayName: "Modal Sandbox",
description: "Create a sandbox in your Modal account.",
pluginId: "environment-modal-sandbox",
icon: "./modal-logo.svg",
logoUrl: modalLogoUrl,
supportsSuspend: true,
});

export const MANUAL_MACHINE_PROVIDER = machineProvider({
id: "manual",
displayName: "Manual machine setup",
description:
"Run one command on a machine you already have to connect it to this server.",
pluginId: "core",
icon: "Terminal",
});

export const MODAL_NEEDS_TOKEN_PROVIDER = machineProvider({
...MODAL_MACHINE_PROVIDER,
});

export const MODAL_UNRENDERABLE_INPUTS_PROVIDER = machineProvider({
...MODAL_MACHINE_PROVIDER,
inputs: {
type: "object",
properties: { region: { type: "string" } },
required: ["region"],
},
acceptsEmptyInputs: false,
});

export const UNAVAILABLE_MACHINE_PROVIDER = machineProvider({
id: "fleet",
displayName: "Fleet",
description: "Rent a machine from a managed fleet.",
icon: "Server",
});
10 changes: 10 additions & 0 deletions apps/app/.ladle/settings-story-fixtures.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,11 @@ import {
} from "../src/hooks/useUpdateInventory";
import { createAppQueryClient } from "../src/lib/query-client";
import { makeSystemConfig } from "../src/test/fixtures/system-config";
import { systemMachineProvidersQueryKey } from "../src/hooks/queries/query-keys";
import {
MANUAL_MACHINE_PROVIDER,
MODAL_MACHINE_PROVIDER,
} from "./machine-story-fixtures";
import { makeProviderInfo } from "@bb/test-helpers/domain-fixtures";
import { getSettingsRoutePath } from "../src/lib/route-paths";
import {
Expand Down Expand Up @@ -278,6 +283,7 @@ function createSettingsStoryQueryClient() {
},
});
queryClient.setQueryData(hostsQueryKey(), SETTINGS_STORY_HOSTS);
queryClient.setQueryData(hostsQueryKey(true), SETTINGS_STORY_HOSTS);
queryClient.setQueryData(systemConfigQueryKey(), systemConfig);
queryClient.setQueryData(systemProvidersQueryKey(), systemProviders);
queryClient.setQueryData(systemVersionQueryKey(), systemVersion);
Expand All @@ -292,6 +298,10 @@ function createSettingsStoryQueryClient() {
remoteProviderStatus,
);
queryClient.setQueryData(pluginListQueryKey(true), []);
queryClient.setQueryData(systemMachineProvidersQueryKey(), [
MANUAL_MACHINE_PROVIDER,
MODAL_MACHINE_PROVIDER,
]);
return queryClient;
}

Expand Down
27 changes: 25 additions & 2 deletions apps/app/.ladle/story-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {
import type {
ProjectResponse,
SystemEnvironmentProvider,
SystemMachineProvider,
} from "@bb/server-contract";
import { EMPTY_ORDERED_MENTION_SUGGESTIONS } from "@bb/client-core";
import {
Expand Down Expand Up @@ -103,8 +104,10 @@ export function makeAttachmentsConfig(
}

function storyProviderIcon(providerId: string, glyph: string) {
return getProviderIconInfo(providerId, { logoUrl: null, icon: { glyph } })
?.icon;
return getProviderIconInfo("agent", providerId, {
logoUrl: null,
icon: { glyph },
})?.icon;
}

function makeStoryProvider(
Expand Down Expand Up @@ -323,8 +326,10 @@ export const STORY_WORKTREE_OPTIONS: readonly ReuseThreadOption[] = [
export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] =
[
{
machineProviderId: null,
id: "project-checkout",
displayName: "Project checkout",
description: "Work in this project checkout.",
icon: "Laptop",
logoUrl: null,
pluginId: "environment-project-checkout",
Expand All @@ -340,8 +345,10 @@ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] =
inputs: null,
},
{
machineProviderId: null,
id: "git-worktree",
displayName: "Worktree",
description: "Create an isolated Git worktree.",
icon: "GitBranch",
logoUrl: null,
pluginId: "environment-git-worktree",
Expand All @@ -357,8 +364,10 @@ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] =
inputs: null,
},
{
machineProviderId: null,
id: "personal-workspace",
displayName: "Personal workspace",
description: "Create a personal directory without a project.",
icon: "Folder",
logoUrl: null,
pluginId: "environment-personal-workspace",
Expand All @@ -375,6 +384,20 @@ export const STORY_ENVIRONMENT_PROVIDERS: readonly SystemEnvironmentProvider[] =
},
];

export const STORY_MACHINE_PROVIDERS: readonly SystemMachineProvider[] = [
{
id: "modal-sandbox",
displayName: "Modal Sandbox",
description: "Run a machine for development.",
icon: "Box",
logoUrl: null,
pluginId: "environment-modal-sandbox",
inputs: null,
acceptsEmptyInputs: true,
supportsSuspend: true,
},
];

export const STORY_PROJECTS: readonly ProjectSelectorOption[] = [
{ id: PROJECT_IDS.bb, name: PROJECT_NAMES.bb },
{ id: PROJECT_IDS.pierre, name: PROJECT_NAMES.pierre },
Expand Down
58 changes: 58 additions & 0 deletions apps/app/src/components/dialogs/AddMachineDialog.expiry.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
// @vitest-environment jsdom

import { act, cleanup, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { MachineLaunchCommand } from "./AddMachineDialog";

const COMMAND =
"curl -fsSL -H 'X-BB-Enrollment: secret' https://bb/install.sh | sh";

describe("MachineLaunchCommand", () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(0);
});
afterEach(() => {
cleanup();
vi.useRealTimers();
});

it("counts the remaining time down while the command is still valid", () => {
render(
<MachineLaunchCommand
command={COMMAND}
expiresAt={15 * 60_000}
onRegenerate={() => {}}
/>,
);
expect(screen.getByRole("status").textContent).toBe(
"Command expires in 15:00",
);
act(() => void vi.advanceTimersByTime(61_000));
expect(screen.getByRole("status").textContent).toBe(
"Command expires in 13:59",
);
expect(screen.getByText(COMMAND)).toBeTruthy();
});

it("stops offering a copy and offers a replacement once it expires", () => {
const onRegenerate = vi.fn();
render(
<MachineLaunchCommand
command={COMMAND}
expiresAt={5_000}
onRegenerate={onRegenerate}
/>,
);
expect(
screen.getByRole("button", { name: "Copy" }).hasAttribute("disabled"),
).toBe(false);
act(() => void vi.advanceTimersByTime(6_000));
expect(screen.getByRole("status").textContent).toBe("Command expired");
expect(
screen.getByRole("button", { name: "Copy" }).hasAttribute("disabled"),
).toBe(true);
screen.getByRole("button", { name: "Generate a new command" }).click();
expect(onRegenerate).toHaveBeenCalledTimes(1);
});
});
Loading
Loading