Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 31 additions & 5 deletions web/app/api/vm/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,7 @@ export async function POST(request: Request): Promise<Response> {
imageVersion: imageSelection.imageVersion,
provider,
idempotencyKey,
persistentHome: candidate.persistentHome === true,
perMachineHome: candidate.perMachineHome === true,
...homeVolumeOptionsFor(provider, candidate, span),
memoryMb,
imageSize: imageSelection.size ?? undefined,
modelPlane,
Expand Down Expand Up @@ -281,17 +280,44 @@ export async function POST(request: Request): Promise<Response> {
);
}

/**
* The home-volume flags the workflow receives. A provider that does not honor
* `CreateOptions.homeVolume` gets neither flag, so the row never claims a
* volume it does not have; the span records the drop for operators.
*/
function homeVolumeOptionsFor(
provider: ProviderId,
candidate: Record<string, unknown>,
span: Span,
): { readonly persistentHome: boolean; readonly perMachineHome: boolean } {
const requested = {
persistentHome: candidate.persistentHome === true,
perMachineHome: candidate.perMachineHome === true,
};
const supported = vmCapabilitiesFor(provider).persistentHome;
const anyRequested = requested.persistentHome || requested.perMachineHome;
setSpanAttributes(span, {
"cmux.vm.home_volume_requested": anyRequested,
"cmux.vm.home_volume_dropped": anyRequested && !supported,
});
if (supported) return requested;
return { persistentHome: false, perMachineHome: false };
}

async function unsupportedCreateOptionResponse(
provider: ProviderId,
candidate: Record<string, unknown>,
request: Request,
): Promise<Response | null> {
const capabilities = vmCapabilitiesFor(provider);
// Only an explicit, user-chosen option is rejected here. `persistentHome` and
// `perMachineHome` are sent by every shipped `cmux vm new` (they are the
// client's default for a fresh machine, not a person's choice), so a provider
// that ignores home volumes must still create the machine; the route drops
// the flags and records that instead (see `homeVolumeOptionsFor`).
const unsupported = candidate.memoryMb !== undefined && !capabilities.sizing
? { operation: "sizing" as const, field: "memoryMb" }
: (candidate.persistentHome === true || candidate.perMachineHome === true) && !capabilities.persistentHome
? { operation: "persistentHome" as const, field: candidate.persistentHome === true ? "persistentHome" : "perMachineHome" }
: null;
: null;
if (!unsupported) return null;
const copy = await vmUnsupportedCopy(unsupported.operation, vmRequestLocale(request));
return vmErrorResponse({
Expand Down
29 changes: 29 additions & 0 deletions web/tests/vm-route-auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,35 @@ describe("VM REST auth", () => {
expect(runVmWorkflow).toHaveBeenCalled();
});

test("a default client create with home-volume flags succeeds on a provider that ignores them", async () => {
// Every shipped `cmux vm new` sends `persistentHome` + `perMachineHome`.
// Freestyle declares no `persistentHome` capability, so the route must
// drop the flags rather than fail the create with `vm_operation_unsupported`.
getUser.mockResolvedValue(authedStackUser());
runVmWorkflow.mockResolvedValue({
providerVmId: "provider-vm-2",
provider: "freestyle",
image: "snapshot-test",
createdAt: 1_777_000_000_000,
});

const response = await POST(
new Request("https://cmux.test/api/vm", {
method: "POST",
headers: { "idempotency-key": "idem-2", origin: "https://cmux.test" },
body: JSON.stringify({ persistentHome: true, perMachineHome: true }),
}),
);

expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ id: "provider-vm-2", provider: "freestyle" });
expect(createVm).toHaveBeenCalledWith(expect.objectContaining({
provider: "freestyle",
persistentHome: false,
perMachineHome: false,
}));
});

test("rejects an unknown `kind` on create and base open before touching workflows", async () => {
getUser.mockResolvedValue(authedStackUser());

Expand Down
Loading