From 253110f3c90a94ee6c893b51eb1ea58e1bee5203 Mon Sep 17 00:00:00 2001 From: pedrofrxncx Date: Sun, 12 Jul 2026 17:29:15 -0300 Subject: [PATCH] fix(sandbox): scope user-driven SANDBOX_START to the locked provider kind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Auto-start and self-heal already passed sandboxProviderKind to SANDBOX_START so provisioning stays pinned to the thread's locked provider (#4025), but the user-driven start() — which retry() and resume() also call — built its args without it. Retrying a failed locked-provider sandbox (or resuming a suspended one) could get re-provisioned on a different provider than the one the preview is locked to, the exact 'different-provider sibling' the surrounding code says should never happen. Extract the shared arg-builder (buildSandboxStartArgs) so all three call sites stay in sync, and add a regression test. --- .../hooks/sandbox-lifecycle-context.test.ts | 21 ++++++++++ .../hooks/sandbox-lifecycle-context.tsx | 38 +++++++++++++++---- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.test.ts b/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.test.ts index 2547f96c9f..237c46d1d2 100644 --- a/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.test.ts +++ b/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.test.ts @@ -4,6 +4,7 @@ import { shouldAutoStart, shouldSelfHeal, computeDrawerStatus, + buildSandboxStartArgs, type BranchMapEntryLike, } from "./sandbox-lifecycle-context"; @@ -130,6 +131,26 @@ describe("shouldSelfHeal", () => { }); }); +describe("buildSandboxStartArgs", () => { + test("includes sandboxProviderKind when the thread has a locked kind", () => { + // Regression: user-driven start/retry/resume used to omit + // sandboxProviderKind (unlike auto-start/self-heal), so retrying a + // failed locked-provider thread could get provisioned on the wrong + // provider instead of the one the preview is locked to. + expect(buildSandboxStartArgs("vmcp1", "main", "user-desktop")).toEqual({ + virtualMcpId: "vmcp1", + branch: "main", + sandboxProviderKind: "user-desktop", + }); + }); + + test("omits branch and sandboxProviderKind when absent", () => { + expect(buildSandboxStartArgs("vmcp1", null, null)).toEqual({ + virtualMcpId: "vmcp1", + }); + }); +}); + describe("computeDrawerStatus", () => { test("suspended → suspended", () => { expect(computeDrawerStatus({ kind: "suspended" })).toBe("suspended"); diff --git a/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.tsx b/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.tsx index a01ceb86c1..16f457fc33 100644 --- a/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.tsx +++ b/apps/mesh/src/web/components/sandbox/hooks/sandbox-lifecycle-context.tsx @@ -68,6 +68,21 @@ export function shouldSelfHeal(args: ShouldSelfHealArgs): boolean { ); } +/** Shared SANDBOX_START arg-builder so every call site (auto-start, + * self-heal, user-driven start/retry/resume) scopes provisioning to the + * locked provider kind identically — a caller that omits it can get + * provisioned on the wrong provider. */ +export function buildSandboxStartArgs( + virtualMcpId: string, + branch: string | null, + sandboxProviderKind: SandboxProviderKind | null, +): SandboxStartArgs { + const args: SandboxStartArgs = { virtualMcpId }; + if (branch) args.branch = branch; + if (sandboxProviderKind) args.sandboxProviderKind = sandboxProviderKind; + return args; +} + export function computeDrawerStatus(state: PreviewState): DrawerStatus { switch (state.kind) { case "suspended": @@ -239,9 +254,11 @@ export function SandboxLifecycleProvider({ useEffect(() => { if (!autoStartEligible || !virtualMcpId) return; autoStartAttemptedForBranchRef.current.add(autoStartDedupKey); - const args: SandboxStartArgs = { virtualMcpId }; - if (branch) args.branch = branch; - if (sandboxProviderKind) args.sandboxProviderKind = sandboxProviderKind; + const args = buildSandboxStartArgs( + virtualMcpId, + branch, + sandboxProviderKind, + ); startVmMutate(args, { onSuccess: (data) => { if (data?.branch && !branch) setCurrentTaskBranch(data.branch); @@ -276,9 +293,11 @@ export function SandboxLifecycleProvider({ if (!selfHealEligible || !deadVmId || !virtualMcpId) return; // oxlint-disable-next-line ban-ref-current-assignment/ban-ref-current-assignment -- record dead handle to dedup repeat 404s reprovisionedForVmIdRef.current = deadVmId; - const args: SandboxStartArgs = { virtualMcpId }; - if (branch) args.branch = branch; - if (sandboxProviderKind) args.sandboxProviderKind = sandboxProviderKind; + const args = buildSandboxStartArgs( + virtualMcpId, + branch, + sandboxProviderKind, + ); startVmMutate(args, { onSuccess: (data) => { if (data?.branch && !branch) setCurrentTaskBranch(data.branch); @@ -300,8 +319,11 @@ export function SandboxLifecycleProvider({ // User-driven actions. const start = () => { if (!virtualMcpId) return; - const args: SandboxStartArgs = { virtualMcpId }; - if (branch) args.branch = branch; + const args = buildSandboxStartArgs( + virtualMcpId, + branch, + sandboxProviderKind, + ); startVmMutate(args, { onSuccess: (data) => { if (data?.branch && !branch) setCurrentTaskBranch(data.branch);