diff --git a/apps/web/src/app/(onboarding)/setup/StepComputeProvider.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepComputeProvider.client.test.tsx
index 568ac3382..4476b6c2a 100644
--- a/apps/web/src/app/(onboarding)/setup/StepComputeProvider.client.test.tsx
+++ b/apps/web/src/app/(onboarding)/setup/StepComputeProvider.client.test.tsx
@@ -59,6 +59,7 @@ describe('StepComputeProvider', () => {
persistedDefaultProvider: null,
setupSatisfied: false,
setupSatisfiedByRuntimeEnv: false,
+ excludedProviders: [],
workerImage: {
envVarName: 'DOCKER_WORKER_IMAGE',
label: 'Worker Image',
@@ -100,4 +101,45 @@ describe('StepComputeProvider', () => {
expect(screen.getByRole('button', { name: /daytona/i })).toBeTruthy();
expect(screen.getByRole('button', { name: /local docker/i })).toBeTruthy();
});
+
+ it('hides providers excluded by the deployment', () => {
+ const computeSetup: SetupComputeStatus = {
+ selectedProvider: null,
+ preselectedProvider: 'modal',
+ runtimeDefaultProvider: 'modal',
+ persistedDefaultProvider: null,
+ setupSatisfied: false,
+ setupSatisfiedByRuntimeEnv: false,
+ excludedProviders: ['docker'],
+ workerImage: {
+ envVarName: 'DOCKER_WORKER_IMAGE',
+ label: 'Worker Image',
+ runtimeSatisfied: false,
+ savedSatisfied: false,
+ hostedImageRef: null,
+ hostedReady: false,
+ },
+ providers: [
+ buildProvider({
+ provider: 'modal',
+ label: 'Modal',
+ infrastructureSatisfied: false,
+ }),
+ buildProvider({
+ provider: 'docker',
+ label: 'Local Docker',
+ infrastructureSatisfied: true,
+ }),
+ ],
+ };
+
+ render(
+ ,
+ );
+
+ expect(screen.getByRole('button', { name: /modal/i })).toBeTruthy();
+ expect(
+ screen.queryByRole('button', { name: /local docker/i }),
+ ).not.toBeInTheDocument();
+ });
});
diff --git a/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx b/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx
index 806bea9c3..0e5945b09 100644
--- a/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx
+++ b/apps/web/src/app/(onboarding)/setup/StepComputeProvider.tsx
@@ -30,11 +30,13 @@ export function StepComputeProvider({
onBack?: () => void;
disabled?: boolean;
}) {
- // All providers are offered. Hosted providers whose worker image is not yet
- // available are still selectable: their config step collects a shared worker
- // image (and can auto-derive or provision the provider artifact from it), so
- // the operator is no longer dead-ended by missing infrastructure.
- const availableProviders = computeSetup.providers;
+ // Hosted providers whose worker image is not yet available remain selectable:
+ // their config step can collect or provision the missing infrastructure.
+ // Explicit deployment exclusions are stronger and must not be offered.
+ const excludedProviders = new Set(computeSetup.excludedProviders ?? []);
+ const availableProviders = computeSetup.providers.filter(
+ (provider) => !excludedProviders.has(provider.provider),
+ );
return (
diff --git a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx
index 29b597d76..52e3a1486 100644
--- a/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx
+++ b/apps/web/src/app/(onboarding)/setup/hooks.client.test.tsx
@@ -434,6 +434,83 @@ describe('useSetupFlow', () => {
});
});
+ it('requires a new choice when the saved compute provider is excluded', async () => {
+ mockStatus({
+ hasSlack: true,
+ authSetup: {
+ setupSatisfiedByRuntimeEnv: false,
+ selectedProvider: 'slack',
+ preselectedProvider: 'slack',
+ runtimeConfiguredProvider: null,
+ runtimeConfiguredProviders: [],
+ lockReason: null,
+ providers: [
+ {
+ id: 'slack',
+ label: 'Slack',
+ fields: [],
+ runtimeSatisfied: true,
+ savedSatisfied: false,
+ setupSatisfied: true,
+ },
+ ],
+ },
+ modelSetup: {
+ setupSatisfied: true,
+ setupSatisfiedByRuntimeEnv: true,
+ preselectedProvider: 'openrouter',
+ },
+ computeSetup: {
+ setupSatisfied: true,
+ selectedProvider: 'modal',
+ preselectedProvider: 'modal',
+ runtimeDefaultProvider: 'modal',
+ persistedDefaultProvider: null,
+ excludedProviders: ['docker'],
+ providers: [
+ {
+ provider: 'modal',
+ label: 'Modal',
+ description: '',
+ supportsSnapshots: true,
+ fields: [],
+ runtimeConfigSatisfied: true,
+ savedConfigSatisfied: false,
+ configSatisfied: true,
+ },
+ ],
+ },
+ sourceControlSetup: {
+ setupSatisfied: true,
+ setupSatisfiedByRuntimeEnv: false,
+ selectedProvider: 'github',
+ preselectedProvider: 'github',
+ runtimeConfiguredProvider: null,
+ runtimeConfiguredProviders: [],
+ lockReason: null,
+ connectedProvider: 'github',
+ providers: [],
+ },
+ setupNewState: {
+ authProvider: 'slack',
+ modelProvider: 'openrouter',
+ computeProvider: 'docker',
+ sourceControlProvider: 'github',
+ selectedRepositoryIds: [],
+ onboardingTaskId: null,
+ onboardingTaskStartedAt: null,
+ slackChannel: null,
+ slackThreadTs: null,
+ },
+ });
+
+ const { result } = renderHook(() => useSetupFlow());
+
+ await waitFor(() => {
+ expect(result.current.step).toBe('compute-provider');
+ });
+ });
+
it('shows compute-config when a compute provider is chosen but not yet configured', async () => {
mockStatus({
hasSlack: true,
diff --git a/apps/web/src/app/(onboarding)/setup/hooks.ts b/apps/web/src/app/(onboarding)/setup/hooks.ts
index 74786918f..e9ca32f92 100644
--- a/apps/web/src/app/(onboarding)/setup/hooks.ts
+++ b/apps/web/src/app/(onboarding)/setup/hooks.ts
@@ -337,6 +337,10 @@ export function useSetupFlow(
status.setupNewState.authProvider ??
status.authSetup.runtimeConfiguredProvider ??
status.authSetup.selectedProvider;
+ const selectedComputeProvider = status.computeSetup.selectedProvider;
+ const hasStaleComputeProvider =
+ status.setupNewState.computeProvider !== null &&
+ selectedComputeProvider !== status.setupNewState.computeProvider;
switch (candidate) {
case 'welcome':
@@ -389,17 +393,15 @@ export function useSetupFlow(
return activeQualificationBlock === null;
case 'compute-provider':
return (
- status.computeSetup.setupSatisfied ||
- status.setupNewState.computeProvider != null
+ !hasStaleComputeProvider &&
+ (status.computeSetup.setupSatisfied ||
+ selectedComputeProvider !== null)
);
case 'compute-config': {
- if (status.computeSetup.setupSatisfied) {
+ if (hasStaleComputeProvider || status.computeSetup.setupSatisfied) {
return true;
}
- const selectedComputeProvider =
- status.setupNewState.computeProvider ?? null;
-
if (!selectedComputeProvider) {
return true;
}
diff --git a/apps/web/src/trpc/commands/setup-new/index.test.ts b/apps/web/src/trpc/commands/setup-new/index.test.ts
index ca40c8756..048a7d904 100644
--- a/apps/web/src/trpc/commands/setup-new/index.test.ts
+++ b/apps/web/src/trpc/commands/setup-new/index.test.ts
@@ -664,6 +664,32 @@ describe('setup-new compute config commands', () => {
expect(result.runtimeComputeConfig.defaultProvider).toBe('docker');
});
+ it('rejects an excluded provider choice', async () => {
+ vi.stubEnv('EXCLUDED_COMPUTE_PROVIDERS', 'docker');
+
+ await expect(
+ saveSetupNewComputeProviderChoiceCommand(buildMockAuth(), {
+ provider: 'docker',
+ }),
+ ).rejects.toThrow('Selected sandbox provider is unavailable.');
+ });
+
+ it('rejects configuration for an excluded provider', async () => {
+ vi.stubEnv('EXCLUDED_COMPUTE_PROVIDERS', 'modal');
+
+ await expect(
+ saveSetupNewComputeConfigCommand(buildMockAuth(), {
+ provider: 'modal',
+ values: {
+ MODAL_TOKEN_ID: 'token-id',
+ MODAL_TOKEN_SECRET: 'token-secret',
+ },
+ }),
+ ).rejects.toThrow('Selected sandbox provider is unavailable.');
+
+ expect(mockUpsertDeploymentEnvironmentVariables).not.toHaveBeenCalled();
+ });
+
it('persists a Modal base image derived from the worker image', async () => {
vi.stubEnv(
'DOCKER_WORKER_IMAGE',
@@ -985,6 +1011,11 @@ describe('setup-new onboarding task start command', () => {
},
]),
);
+ if (setupNewState?.computeProvider) {
+ mockTxSelect.mockReturnValueOnce(
+ createSelectChain([{ runtimeComputeConfig: null }]),
+ );
+ }
mockTxSelect.mockReturnValueOnce(
createSelectChain(slackInstallation ? [slackInstallation] : []),
);
@@ -1072,6 +1103,22 @@ describe('setup-new onboarding task start command', () => {
);
});
+ it('rejects a stale excluded compute provider before launch', async () => {
+ vi.stubEnv('EXCLUDED_COMPUTE_PROVIDERS', 'docker');
+ mockOnboardingTransaction({
+ slackInstallation: null,
+ setupNewState: { computeProvider: 'docker' },
+ });
+
+ await expect(
+ startSetupNewOnboardingTaskCommand(buildMockAuth()),
+ ).rejects.toThrow(
+ 'Selected sandbox provider is no longer available. Choose another provider before starting setup.',
+ );
+
+ expect(enqueueTask).not.toHaveBeenCalled();
+ });
+
it('creates the onboarding task without dispatching while first-time E2B provisioning is running', async () => {
vi.stubEnv('E2B_TEMPLATE_ID', '');
mockGetPersistedEnvironmentVariableNames.mockResolvedValue(['E2B_API_KEY']);
diff --git a/apps/web/src/trpc/commands/setup-new/index.ts b/apps/web/src/trpc/commands/setup-new/index.ts
index 5da5deb0e..be177bdaa 100644
--- a/apps/web/src/trpc/commands/setup-new/index.ts
+++ b/apps/web/src/trpc/commands/setup-new/index.ts
@@ -80,6 +80,7 @@ import {
SETUP_COMPUTE_PROVISIONING_STATE_FIELDS,
SHARED_WORKER_IMAGE_ENV_VAR,
type SetupAuthProviderId,
+ type SetupComputeStatus,
type SetupModelProviderId,
type SetupProvisionableComputeProvider,
type SourceControlProvider,
@@ -539,6 +540,19 @@ type SetupOnboardingComputeGate = {
error: string | null;
};
+function findAvailableSetupComputeProvider(
+ computeSetup: SetupComputeStatus,
+ provider: ComputeProvider,
+) {
+ if (computeSetup.excludedProviders?.includes(provider)) {
+ return undefined;
+ }
+
+ return computeSetup.providers.find(
+ (candidate) => candidate.provider === provider,
+ );
+}
+
async function getSetupOnboardingComputeGate(
setupNewState: PersistedSetupNewState,
executor: DatabaseOrTransaction,
@@ -1537,8 +1551,9 @@ export async function saveSetupNewComputeProviderChoiceCommand(
persistedComputeConfig: persistedRuntimeComputeConfig,
selectedProvider: input.provider,
});
- const providerStatus = computeSetup.providers.find(
- (candidate) => candidate.provider === input.provider,
+ const providerStatus = findAvailableSetupComputeProvider(
+ computeSetup,
+ input.provider,
);
if (!providerStatus) {
@@ -1602,12 +1617,6 @@ export async function saveSetupNewComputeConfigCommand(
templateRef: string;
} | null = null;
- if (isSetupProvisionableComputeProvider(input.provider)) {
- await acquireComputeProvisioningLock(input.provider, tx);
- }
-
- await purgeSavedDeploymentWorkerImage(tx);
-
const [
currentState,
persistedRuntimeComputeConfig,
@@ -1635,14 +1644,21 @@ export async function saveSetupNewComputeConfigCommand(
persistedComputeConfig: persistedRuntimeComputeConfig,
selectedProvider: input.provider,
});
- const providerStatus = computeSetup.providers.find(
- (candidate) => candidate.provider === input.provider,
+ const providerStatus = findAvailableSetupComputeProvider(
+ computeSetup,
+ input.provider,
);
if (!providerStatus) {
throw new Error('Selected sandbox provider is unavailable.');
}
+ if (isSetupProvisionableComputeProvider(input.provider)) {
+ await acquireComputeProvisioningLock(input.provider, tx);
+ }
+
+ await purgeSavedDeploymentWorkerImage(tx);
+
// Derive MODAL_BASE_IMAGE_REF when not env-provided or already saved.
// Form submissions are ignored (deployment-managed like E2B/Daytona
// artifacts).
@@ -2266,6 +2282,22 @@ export async function startSetupNewOnboardingTaskCommand(
};
}
+ if (currentState.computeProvider) {
+ const persistedRuntimeComputeConfig =
+ await getPersistedRuntimeComputeConfig(tx);
+ const computeSetup = buildSetupComputeStatus({
+ runtimeEnv: process.env,
+ persistedComputeConfig: persistedRuntimeComputeConfig,
+ selectedProvider: currentState.computeProvider,
+ });
+
+ if (computeSetup.selectedProvider !== currentState.computeProvider) {
+ throw new Error(
+ 'Selected sandbox provider is no longer available. Choose another provider before starting setup.',
+ );
+ }
+ }
+
const { normalizedRepositoryIds, selectedRepositories } =
await resolveSelectedRepositories(currentState.selectedRepositoryIds);
await assertHasCommittedRepositorySelection(normalizedRepositoryIds);