From 261ddebb3563b4ef6252419c88fbd04bce809d98 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 31 Aug 2026 20:49:03 -0700 Subject: [PATCH 1/8] test: cover paid-only Cloud VM provisioning --- web/tests/cloud-vm-env-audit.test.ts | 5 + web/tests/vm-billing-limit-paywall.test.ts | 22 +- web/tests/vm-pro-gate.test.ts | 40 +++- web/tests/vm-route-auth.test.ts | 229 +++++++++++++++++++-- 4 files changed, 265 insertions(+), 31 deletions(-) diff --git a/web/tests/cloud-vm-env-audit.test.ts b/web/tests/cloud-vm-env-audit.test.ts index 3965d233d900..fae51c7c985a 100644 --- a/web/tests/cloud-vm-env-audit.test.ts +++ b/web/tests/cloud-vm-env-audit.test.ts @@ -204,4 +204,9 @@ describe("required runtime env keys cover the production provider path", () => { expect(requiredRuntimeEnvKeys).not.toContain(key); } }); + + test("the free-provisioning escape hatch is visible but never required", () => { + expect(recommendedRuntimeEnvKeys).toContain("CMUX_VM_ALLOW_FREE_PROVISIONING"); + expect(requiredRuntimeEnvKeys).not.toContain("CMUX_VM_ALLOW_FREE_PROVISIONING"); + }); }); diff --git a/web/tests/vm-billing-limit-paywall.test.ts b/web/tests/vm-billing-limit-paywall.test.ts index e2eb99f30ccc..154585b04156 100644 --- a/web/tests/vm-billing-limit-paywall.test.ts +++ b/web/tests/vm-billing-limit-paywall.test.ts @@ -21,9 +21,25 @@ describe("free plan VM allowance", () => { expect(maxActiveVmsForPlan("pro", {})).toBe(5); }); - test("the free allowance stays env-overridable, including back to a demo allowance", () => { - expect(maxActiveVmsForPlan("free", { CMUX_VM_FREE_MAX_ACTIVE_VMS: "7" })).toBe(7); - expect(maxActiveVmsForPlan("free", { CMUX_VM_FREE_MAX_ACTIVE_VMS: "0" })).toBe(0); + test("free allowance is env-overridable only with the explicit escape hatch", () => { + expect(maxActiveVmsForPlan("free", { CMUX_VM_FREE_MAX_ACTIVE_VMS: "7" })).toBe(0); + expect(maxActiveVmsForPlan("free", { + CMUX_VM_ALLOW_FREE_PROVISIONING: "1", + CMUX_VM_FREE_MAX_ACTIVE_VMS: "7", + })).toBe(7); + expect(maxActiveVmsForPlan("free", { + CMUX_VM_ALLOW_FREE_PROVISIONING: "1", + CMUX_VM_FREE_MAX_ACTIVE_VMS: "0", + })).toBe(0); + }); + + test("a plan-specific free override cannot bypass the default gate", () => { + expect(maxActiveVmsForPlan("free", { + CMUX_VM_PLAN_FREE_MAX_ACTIVE_VMS: "9", + })).toBe(0); + expect(maxActiveVmsForPlan("unknown", { + CMUX_VM_PLAN_UNKNOWN_MAX_ACTIVE_VMS: "9", + })).toBe(0); }); }); diff --git a/web/tests/vm-pro-gate.test.ts b/web/tests/vm-pro-gate.test.ts index dd4631bf36a4..42949d565bb9 100644 --- a/web/tests/vm-pro-gate.test.ts +++ b/web/tests/vm-pro-gate.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test"; import { isPaidVmPlan, + isVmFreeProvisioningAllowed, isVmProGateBlocked, isVmProGateEnforced, } from "../services/vms/entitlements"; @@ -22,25 +23,42 @@ describe("Cloud VM Pro gate", () => { expect(isPaidVmPlan("enterprise-unknown")).toBe(false); }); - test("enforcement is off unless CMUX_VM_REQUIRE_PRO is truthy (ships dark)", () => { - expect(isVmProGateEnforced({})).toBe(false); - expect(isVmProGateEnforced({ CMUX_VM_REQUIRE_PRO: "" })).toBe(false); - expect(isVmProGateEnforced({ CMUX_VM_REQUIRE_PRO: "0" })).toBe(false); - expect(isVmProGateEnforced({ CMUX_VM_REQUIRE_PRO: "false" })).toBe(false); + test("enforcement is on by default and only an explicit allow switch opens it", () => { + expect(isVmProGateEnforced({})).toBe(true); + expect(isVmProGateEnforced({ CMUX_VM_REQUIRE_PRO: "" })).toBe(true); expect(isVmProGateEnforced({ CMUX_VM_REQUIRE_PRO: "1" })).toBe(true); - expect(isVmProGateEnforced({ CMUX_VM_REQUIRE_PRO: "true" })).toBe(true); - expect(isVmProGateEnforced({ CMUX_VM_REQUIRE_PRO: "ON" })).toBe(true); + expect(isVmProGateEnforced({ CMUX_VM_ALLOW_FREE_PROVISIONING: "" })).toBe(true); + expect(isVmProGateEnforced({ CMUX_VM_ALLOW_FREE_PROVISIONING: "0" })).toBe(true); + expect(isVmProGateEnforced({ CMUX_VM_ALLOW_FREE_PROVISIONING: "false" })).toBe(true); + expect(isVmProGateEnforced({ CMUX_VM_ALLOW_FREE_PROVISIONING: "1" })).toBe(false); + expect(isVmProGateEnforced({ CMUX_VM_ALLOW_FREE_PROVISIONING: "ON" })).toBe(false); }); - test("enforcement OFF never blocks any plan", () => { - expect(isVmProGateBlocked(ent("free"), {})).toBe(false); + test("legacy CMUX_VM_REQUIRE_PRO false values remain a compatibility escape hatch", () => { + expect(isVmFreeProvisioningAllowed({ CMUX_VM_REQUIRE_PRO: "0" })).toBe(true); + expect(isVmFreeProvisioningAllowed({ CMUX_VM_REQUIRE_PRO: "false" })).toBe(true); + expect(isVmFreeProvisioningAllowed({ CMUX_VM_REQUIRE_PRO: "off" })).toBe(true); + expect(isVmFreeProvisioningAllowed({ CMUX_VM_REQUIRE_PRO: "garbage" })).toBe(false); + // The new switch is authoritative when both names are present. + expect(isVmFreeProvisioningAllowed({ + CMUX_VM_ALLOW_FREE_PROVISIONING: "0", + CMUX_VM_REQUIRE_PRO: "0", + })).toBe(false); + }); + + test("the explicit free-provisioning switch never blocks any plan", () => { + const env = { CMUX_VM_ALLOW_FREE_PROVISIONING: "1" }; + expect(isVmProGateBlocked(ent("free"), env)).toBe(false); expect(isVmProGateBlocked(ent("pro"), {})).toBe(false); }); - test("enforcement ON blocks free but allows pro/team", () => { - const env = { CMUX_VM_REQUIRE_PRO: "1" }; + test("default enforcement blocks every non-paid plan but allows pro/team/founders", () => { + const env = {}; expect(isVmProGateBlocked(ent("free"), env)).toBe(true); + expect(isVmProGateBlocked(ent(""), env)).toBe(true); + expect(isVmProGateBlocked(ent("unknown"), env)).toBe(true); expect(isVmProGateBlocked(ent("pro"), env)).toBe(false); expect(isVmProGateBlocked(ent("team"), env)).toBe(false); + expect(isVmProGateBlocked(ent("founders"), env)).toBe(false); }); }); diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index ca30e8054067..6565c1ef9fcf 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -35,6 +35,8 @@ const VM_ENV_KEYS = [ "CMUX_VM_PLAN_PRO_MAX_MEMORY_MB", "CMUX_VM_PLAN_PRO_DEFAULT_MEMORY_MB", "CMUX_VM_REQUIRE_PRO", + "CMUX_VM_ALLOW_FREE_PROVISIONING", + "CMUX_VM_DEFAULT_PLAN", "CMUX_VM_DEFAULT_PROVIDER", "VERCEL", "VERCEL_ENV", @@ -594,6 +596,7 @@ describe("VM REST auth", () => { }); test("rejects a memory size above the plan ceiling before the workflow", async () => { + process.env.CMUX_VM_ALLOW_FREE_PROVISIONING = "1"; getUser.mockResolvedValue(freePlanStackUser()); const response = await POST( @@ -630,9 +633,53 @@ describe("VM REST auth", () => { expect(runVmWorkflow).not.toHaveBeenCalled(); }); - test("blocks a free plan from provisioning when CMUX_VM_REQUIRE_PRO is enforced", async () => { - process.env.CMUX_VM_REQUIRE_PRO = "1"; + test("blocks a free plan by default even when its legacy active limit is permissive", async () => { + process.env.CMUX_VM_FREE_MAX_ACTIVE_VMS = "5"; + getUser.mockResolvedValue(freePlanStackUser()); + + const response = await POST( + new Request("https://cmux.test/api/vm", { + method: "POST", + headers: { origin: "https://cmux.test" }, + body: JSON.stringify({ provider: "freestyle", image: "snapshot-test" }), + }), + ); + + expect(response.status).toBe(402); + expect((await response.json() as { error: string }).error).toBe("vm_requires_pro"); + expect(createVm).not.toHaveBeenCalled(); + }); + + test("an explicit free-provisioning switch reopens the configured demo allowance", async () => { + process.env.CMUX_VM_ALLOW_FREE_PROVISIONING = "1"; + process.env.CMUX_VM_FREE_MAX_ACTIVE_VMS = "5"; getUser.mockResolvedValue(freePlanStackUser()); + runVmWorkflow.mockResolvedValue({ + providerVmId: "provider-vm-free-demo", + provider: "freestyle", + image: "snapshot-test", + imageVersion: null, + createdAt: 1_777_000_000_000, + }); + + const response = await POST( + new Request("https://cmux.test/api/vm", { + method: "POST", + headers: { origin: "https://cmux.test" }, + body: JSON.stringify({ provider: "freestyle", image: "snapshot-test" }), + }), + ); + + expect(response.status).toBe(200); + expect(createVm).toHaveBeenCalledWith(expect.objectContaining({ + billingPlanId: "free", + maxActiveVms: 5, + })); + }); + + test("a paid deployment default cannot grant a paid plan to an account without metadata", async () => { + process.env.CMUX_VM_DEFAULT_PLAN = "pro"; + getUser.mockResolvedValue(stackUserForPlan(undefined)); const response = await POST( new Request("https://cmux.test/api/vm", { @@ -670,6 +717,158 @@ describe("VM REST auth", () => { expect(createVm).toHaveBeenCalled(); }); + test("every provisioning route applies the paid-plan gate before workflow/provider work", async () => { + const provisioningRoutes = [ + { + name: "create", + constructor: createVm, + invoke: () => POST( + new Request("https://cmux.test/api/vm", { + method: "POST", + headers: { origin: "https://cmux.test" }, + // Deliberately use an unknown image and disable provider creates: + // a free caller must still receive the entitlement response first. + body: JSON.stringify({ provider: "freestyle", image: "not-a-real-image" }), + }), + ), + }, + { + name: "base-open", + constructor: openBaseVm, + invoke: () => baseOpenRoute.POST( + new Request("https://cmux.test/api/vm/base/open", { + method: "POST", + headers: { origin: "https://cmux.test" }, + body: JSON.stringify({ provider: "freestyle", image: "not-a-real-image" }), + }), + ), + }, + { + name: "base-reset", + constructor: resetBaseVm, + invoke: () => baseResetRoute.POST( + new Request("https://cmux.test/api/vm/base/reset", { + method: "POST", + headers: { origin: "https://cmux.test" }, + body: JSON.stringify({ provider: "freestyle", image: "not-a-real-image" }), + }), + ), + }, + { + name: "fork", + constructor: forkVm, + invoke: () => forkRoute.POST( + new Request("https://cmux.test/api/vm/provider-vm-1/fork", { + method: "POST", + headers: { origin: "https://cmux.test" }, + body: "{}", + }), + { params: Promise.resolve({ id: "provider-vm-1" }) }, + ), + }, + { + name: "restore", + constructor: restoreVm, + invoke: () => restoreRoute.POST( + new Request("https://cmux.test/api/vm/restore", { + method: "POST", + headers: { origin: "https://cmux.test" }, + body: JSON.stringify({ snapshotId: "snapshot-1", provider: "freestyle" }), + }), + ), + }, + ] as const; + + const provisioningConstructors = [createVm, openBaseVm, resetBaseVm, forkVm, restoreVm]; + const workflowResult = (name: (typeof provisioningRoutes)[number]["name"]): unknown => { + if (name === "fork") { + return { + snapshot: null, + fork: { + providerVmId: "provider-vm-forked", + provider: "freestyle", + image: "snapshot-test", + imageVersion: null, + status: "running", + createdAt: 1_777_000_000_000, + }, + }; + } + if (name === "base-open" || name === "base-reset") { + return { + providerVmId: `provider-vm-${name}`, + provider: "freestyle", + image: "snapshot-test", + imageVersion: null, + status: "running", + createdAt: 1_777_000_000_000, + baseId: "base-1", + baseName: "Base", + generation: 1, + retainedProviderVmId: null, + }; + } + return { + providerVmId: `provider-vm-${name}`, + provider: "freestyle", + image: "snapshot-test", + imageVersion: null, + status: "running", + createdAt: 1_777_000_000_000, + }; + }; + + // Free and no-plan accounts are denied on every allocation surface. The + // provider kill switch is also off here to prove entitlement wins before + // provider/configuration checks. + for (const plan of ["free", undefined]) { + process.env.CMUX_VM_CREATE_ENABLED = "0"; + process.env.CMUX_VM_ALLOW_FREE_PROVISIONING = "0"; + delete process.env.CMUX_VM_REQUIRE_PRO; + delete process.env.CMUX_VM_DEFAULT_PLAN; + process.env.CMUX_VM_FREE_MAX_ACTIVE_VMS = "5"; + getUser.mockResolvedValue(stackUserForPlan(plan)); + for (const route of provisioningRoutes) { + for (const constructor of provisioningConstructors) constructor.mockClear(); + runVmWorkflow.mockClear(); + const response = await route.invoke(); + expect(response.status).toBe(402); + const payload = await response.json() as { + error?: string; + upgradeRequired?: boolean; + upgradeUrl?: string; + }; + expect(payload.error).toBe("vm_requires_pro"); + expect(payload.upgradeRequired).toBe(true); + expect(payload.upgradeUrl).toBe("https://cmux.com/pricing"); + for (const constructor of provisioningConstructors) { + expect(constructor).not.toHaveBeenCalled(); + } + expect(runVmWorkflow).not.toHaveBeenCalled(); + } + } + + // All paid plan ids recognized by the server gate continue through their + // corresponding workflow, including Founder's Edition operator grants. + process.env.CMUX_VM_CREATE_ENABLED = "1"; + process.env.CMUX_VM_ALLOW_FREE_PROVISIONING = "0"; + process.env.CMUX_VM_ALLOW_UNMANIFESTED_IMAGES = "1"; + process.env.CMUX_VM_FREESTYLE_ENABLED = "1"; + delete process.env.CMUX_VM_REQUIRE_PRO; + for (const plan of ["pro", "team", "founders"] as const) { + getUser.mockResolvedValue(stackUserForPlan(plan)); + for (const route of provisioningRoutes) { + for (const constructor of provisioningConstructors) constructor.mockClear(); + runVmWorkflow.mockClear(); + runVmWorkflow.mockResolvedValue(workflowResult(route.name)); + const response = await route.invoke(); + expect(response.status).toBe(200); + expect(runVmWorkflow).toHaveBeenCalledTimes(1); + expect(route.constructor).toHaveBeenCalledTimes(1); + } + } + }); + test("still lists VMs for a free plan under Pro enforcement (management is not gated)", async () => { process.env.CMUX_VM_REQUIRE_PRO = "1"; getUser.mockResolvedValue(freePlanStackUser()); @@ -825,6 +1024,7 @@ describe("VM REST auth", () => { }); test("uses the native client's requested Stack team for billing", async () => { + process.env.CMUX_VM_ALLOW_FREE_PROVISIONING = "1"; const listTeams = mock(async () => [ { id: "team-1", @@ -876,6 +1076,7 @@ describe("VM REST auth", () => { }); test("validates a JSON body team id only when it differs from the selected team", async () => { + process.env.CMUX_VM_ALLOW_FREE_PROVISIONING = "1"; const listTeams = mock(async () => [ { id: "team-1", @@ -992,6 +1193,7 @@ describe("VM REST auth", () => { }); test("uses the single Stack team when personal team auto-create populated listTeams", async () => { + process.env.CMUX_VM_ALLOW_FREE_PROVISIONING = "1"; const listTeams = mock(async () => [{ id: "team-personal", clientReadOnlyMetadata: { cmuxVmPlan: "free" }, @@ -2143,33 +2345,26 @@ function restoreVmEnv(): void { } function authedStackUser() { - return { - id: "user-1", - displayName: null, - primaryEmail: "user@example.com", - selectedTeam: { - id: "team-1", - clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, - }, - listTeams: async () => [{ - id: "team-1", - clientReadOnlyMetadata: { cmuxVmPlan: "pro" }, - }], - }; + return stackUserForPlan("pro"); } function freePlanStackUser() { + return stackUserForPlan("free"); +} + +function stackUserForPlan(plan: string | undefined) { + const clientReadOnlyMetadata = plan ? { cmuxVmPlan: plan } : {}; return { id: "user-1", displayName: null, primaryEmail: "user@example.com", selectedTeam: { id: "team-1", - clientReadOnlyMetadata: { cmuxVmPlan: "free" }, + clientReadOnlyMetadata, }, listTeams: async () => [{ id: "team-1", - clientReadOnlyMetadata: { cmuxVmPlan: "free" }, + clientReadOnlyMetadata, }], }; } From 3f9dd32dcb566699df5b48152c687658d07e4d2f Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 31 Aug 2026 20:50:30 -0700 Subject: [PATCH 2/8] vm: enforce paid-plan provisioning by default --- Resources/Localizable.xcstrings | 17 +++ Sources/Cloud/MachinesPanelViewModel.swift | 17 ++- Sources/Cloud/VMClient.swift | 5 + cmuxTests/MachinesPanelModelTests.swift | 14 +++ docs/cloud-vm-backend-rollout-todo.md | 2 + web/.env.example | 14 +++ web/app/api/vm/[id]/fork/route.ts | 23 +--- web/app/api/vm/base/routeShared.ts | 24 +---- web/app/api/vm/restore/route.ts | 54 ++++------ web/app/api/vm/route.ts | 119 ++++++++++----------- web/app/env.ts | 9 ++ web/scripts/cloud-vm/projects.mjs | 3 + web/services/vms/README.md | 14 ++- web/services/vms/entitlements.ts | 79 +++++++++++--- web/services/vms/routeHelpers.ts | 45 +++++++- 15 files changed, 277 insertions(+), 162 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index c6f5bdfb761d..c8cbd1a2b304 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -126801,6 +126801,23 @@ } } }, + "cloudVM.error.requiresPro.action": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade to cmux Pro at https://cmux.com/pricing to create Cloud VMs." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Cloud VM を作成するには https://cmux.com/pricing で cmux Pro にアップグレードしてください。" + } + } + } + }, "machines.empty.upgrade": { "extractionState": "manual", "localizations": { diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index abf1f14a5a25..8e4945204454 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -91,7 +91,20 @@ struct MachinePlanSnapshot: Equatable { var freeAccessBanner: FreeAccessBanner = .none var isAtLimit: Bool { activeCount >= maxActiveVms } - var isPaidPlan: Bool { planId != "free" } + /// Only plans the backend accepts for provisioning are paid. Unknown plan + /// ids fail closed here too, so a stale metadata value cannot hide the + /// upgrade affordance after the server returns `vm_requires_pro`. + var isPaidPlan: Bool { Self.isPaidPlanID(planId) } + + static func isPaidPlanID(_ planId: String) -> Bool { + switch planId.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "pro", "team", "founders": + return true + default: + return false + } + } + /// Single-machine plans (free) read "1 of 1 machine", never "machines". var isSingleMachinePlan: Bool { maxActiveVms == 1 } @@ -280,7 +293,7 @@ enum MachineSnapshotBuilder { now: Date = Date() ) -> MachinePlanSnapshot? { guard let limits else { return nil } - let isPaidPlan = limits.planId != "free" + let isPaidPlan = MachinePlanSnapshot.isPaidPlanID(limits.planId) let expiresAt = isPaidPlan ? nil : earliestFreeAccessExpiry(limits: limits, machines: machines) return MachinePlanSnapshot( activeCount: activeCount, diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift index 2974a2a17c17..8eedb6576fe7 100644 --- a/Sources/Cloud/VMClient.swift +++ b/Sources/Cloud/VMClient.swift @@ -131,6 +131,11 @@ private func defaultCloudVMAction(status: Int, errorCode: String) -> String { return "Run `cmux vm ls` to see available Cloud VMs. If the VM was paused or destroyed, start a fresh one with `cmux vm new`." case "vm_billing_team_required": return "Select a team in cmux, then retry. You can also run `cmux auth status` to check the signed-in account." + case "vm_requires_pro": + return String( + localized: "cloudVM.error.requiresPro.action", + defaultValue: "Upgrade to cmux Pro at https://cmux.com/pricing to create Cloud VMs." + ) case "vm_create_credits_insufficient": return "Ask a team admin to upgrade the plan or grant more Cloud VM create credits, then retry." default: diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index 62d4dc10daa3..9dd98ab286f7 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -108,6 +108,20 @@ final class MachinesPanelModelTests: XCTestCase { XCTAssertEqual(paid?.isPaidPlan, true) } + func testOnlyProvisioningPlansArePaid() { + XCTAssertTrue(MachinePlanSnapshot.isPaidPlanID("pro")) + XCTAssertTrue(MachinePlanSnapshot.isPaidPlanID("TEAM")) + XCTAssertTrue(MachinePlanSnapshot.isPaidPlanID("founders")) + XCTAssertFalse(MachinePlanSnapshot.isPaidPlanID("free")) + XCTAssertFalse(MachinePlanSnapshot.isPaidPlanID("unknown")) + } + + func testRequiresProErrorIncludesUpgradePathWhenServerOmitsAction() { + let error = VMClientError.httpStatus(402, "{\"error\":\"vm_requires_pro\"}") + XCTAssertTrue(error.description.contains("https://cmux.com/pricing")) + XCTAssertTrue(error.description.contains("Upgrade to cmux Pro")) + } + func testMachinesModeIsRegisteredEverywhere() { XCTAssertTrue(RightSidebarMode.allCases.contains(.machines)) XCTAssertEqual(RightSidebarMode.from(cliArgument: "machines"), .machines) diff --git a/docs/cloud-vm-backend-rollout-todo.md b/docs/cloud-vm-backend-rollout-todo.md index 8f567d1b7d79..306c844acc30 100644 --- a/docs/cloud-vm-backend-rollout-todo.md +++ b/docs/cloud-vm-backend-rollout-todo.md @@ -114,6 +114,8 @@ These are already configured in Vercel for development, preview, and production: - [ ] Add runtime VM vars to the relevant `~/.secrets/cmuxterm*.env` file: - `CMUX_VM_DEFAULT_PROVIDER` - `CMUX_VM_CREATE_ENABLED` + - `CMUX_VM_ALLOW_FREE_PROVISIONING` (leave unset; paid-plan gate is the safe default) + - `CMUX_VM_REQUIRE_PRO` (legacy compatibility alias only) - `CMUX_VM_E2B_ENABLED` - `CMUX_VM_FREESTYLE_ENABLED` - `E2B_CMUXD_WS_TEMPLATE` diff --git a/web/.env.example b/web/.env.example index 4f3d858842ac..af4b6d4a9bc7 100644 --- a/web/.env.example +++ b/web/.env.example @@ -118,9 +118,19 @@ DIRECT_DATABASE_URL= # Global create kill switch. Set to 0/false/off to block new paid provider creates while keeping # list, attach, and delete endpoints available. CMUX_VM_CREATE_ENABLED=1 +# Cloud VM provisioning is paid-plan-only by default. Leave this unset in shared environments; +# set to 1 only for a deliberate local/demo rollback. While unset, free-plan active-limit env vars +# are ignored and every create/fork/restore/Base allocation returns vm_requires_pro. +CMUX_VM_ALLOW_FREE_PROVISIONING= +# Legacy compatibility alias. Unset means the paid-plan gate is on; 0/false/off preserves the old +# permissive behavior only when CMUX_VM_ALLOW_FREE_PROVISIONING is absent. Prefer the new name. +CMUX_VM_REQUIRE_PRO= # Which provider a fresh `cmux vm new` picks if the client doesn't specify one. # Blaxel is the default interactive provider; other providers are explicit rollback paths. CMUX_VM_DEFAULT_PROVIDER=blaxel +# Optional fallback plan for accounts with no billing metadata. Keep this at free; paid defaults +# are ignored unless CMUX_VM_ALLOW_FREE_PROVISIONING=1. +CMUX_VM_DEFAULT_PLAN=free # Dev-only escape hatch for image experiments. Leave unset in Vercel production/staging/preview so # image ids must be present in services/vms/images/manifest.json. CMUX_VM_ALLOW_UNMANIFESTED_IMAGES= @@ -168,6 +178,10 @@ CMUX_VM_CREATE_CREDIT_COST= CMUX_VM_CREATE_CREDIT_COST_E2B= CMUX_VM_CREATE_CREDIT_COST_FREESTYLE= CMUX_VM_CREATE_CREDIT_ITEM_ID= +# Active-machine ceilings. The free value is ignored unless +# CMUX_VM_ALLOW_FREE_PROVISIONING=1 (the paid-plan gate is fail-closed by default). +CMUX_VM_FREE_MAX_ACTIVE_VMS=0 +CMUX_VM_PAID_MAX_ACTIVE_VMS=5 # cmux Vault cloud sync. Leave CMUX_VAULT_S3_BUCKET empty to disable the upload, # commit, and download routes. CMUX_VAULT_S3_ENDPOINT is for S3-compatible diff --git a/web/app/api/vm/[id]/fork/route.ts b/web/app/api/vm/[id]/fork/route.ts index 16803745106b..3fdeac0e194a 100644 --- a/web/app/api/vm/[id]/fork/route.ts +++ b/web/app/api/vm/[id]/fork/route.ts @@ -3,21 +3,15 @@ import { jsonResponse, notFoundVm, requestedVmTeamIdFromRequest, - vmBillingTeamErrorResponse, vmCreateLikeErrorResponse, withAuthedVmApiRoute, - vmRequiresProResponse, + resolveVmProvisioningAccountScope, } from "../../../../../services/vms/routeHelpers"; import { setSpanAttributes } from "../../../../../services/telemetry"; import { captureVmProvisionOutcome } from "../../../../../services/vms/observability"; import { isVmNotFoundError, } from "../../../../../services/vms/errors"; -import { - isVmBillingTeamResolutionError, - isVmProGateBlocked, - resolveVmEntitlements, -} from "../../../../../services/vms/entitlements"; import { forkVm, runVmWorkflow } from "../../../../../services/vms/workflows"; import { VmTimingRecorder } from "../../../../../services/vms/timings"; import { authProviderErrorResponse } from "../../../../../services/vms/authErrors"; @@ -66,18 +60,9 @@ export async function POST( if (!refreshedUser) return unauthorized(); user = refreshedUser; } - let entitlements; - try { - entitlements = resolveVmEntitlements(user, process.env, { - requestedBillingTeamId, - }); - } catch (err) { - if (isVmBillingTeamResolutionError(err)) return vmBillingTeamErrorResponse(err); - throw err; - } - if (isVmProGateBlocked(entitlements)) { - return vmRequiresProResponse(); - } + const account = resolveVmProvisioningAccountScope(user, request, { requestedBillingTeamId }); + if (!account.ok) return account.response; + const entitlements = account.entitlements; const idempotencyKey = idempotencyKeyFromRequest(request); const name = stringField(body, "name"); setSpanAttributes(span, { diff --git a/web/app/api/vm/base/routeShared.ts b/web/app/api/vm/base/routeShared.ts index c4ec1e5bfafe..1a87094d99fa 100644 --- a/web/app/api/vm/base/routeShared.ts +++ b/web/app/api/vm/base/routeShared.ts @@ -1,11 +1,6 @@ import type { AuthedUser } from "../../../../services/vms/auth"; import { assertVmCreateEnabled } from "../../../../services/vms/config"; import { defaultProviderId, isProviderId, type ProviderId } from "../../../../services/vms/drivers"; -import { - isVmBillingTeamResolutionError, - isVmProGateBlocked, - resolveVmEntitlements, -} from "../../../../services/vms/entitlements"; import { isVmCreateCreditsInsufficientError, isVmCreateDisabledError, @@ -28,11 +23,10 @@ import { import { jsonResponse, requestedVmTeamIdFromRequest, - vmBillingTeamErrorResponse, vmActiveLimitExceededResponse, vmErrorResponse, vmWorkflowErrorResponse, - vmRequiresProResponse, + resolveVmProvisioningAccountScope, } from "../../../../services/vms/routeHelpers"; import { vmRequestLocale } from "../../../../services/vms/vmErrorMessages"; import type { VmTimingRecorder } from "../../../../services/vms/timings"; @@ -56,19 +50,9 @@ export async function runBaseRoute(input: { if (!parsed.ok) return parsed.response; const requestedBillingTeamId = parsed.body.billingTeamId || requestedVmTeamIdFromRequest(input.request); - let entitlements; - try { - entitlements = resolveVmEntitlements(input.user, process.env, { - requestedBillingTeamId, - }); - } catch (err) { - if (isVmBillingTeamResolutionError(err)) return vmBillingTeamErrorResponse(err); - throw err; - } - - if (isVmProGateBlocked(entitlements)) { - return vmRequiresProResponse(); - } + const account = resolveVmProvisioningAccountScope(input.user, input.request, { requestedBillingTeamId }); + if (!account.ok) return account.response; + const entitlements = account.entitlements; // Same provider inference as POST /api/vm: an explicit manifest image // names its own provider even when the deployment default disagrees. diff --git a/web/app/api/vm/restore/route.ts b/web/app/api/vm/restore/route.ts index 98d664de3ebf..8073189f7c85 100644 --- a/web/app/api/vm/restore/route.ts +++ b/web/app/api/vm/restore/route.ts @@ -6,18 +6,12 @@ import { captureVmProvisionOutcome } from "../../../../services/vms/observabilit import { jsonResponse, requestedVmTeamIdFromRequest, - vmBillingTeamErrorResponse, vmCreateLikeErrorResponse, vmErrorResponse, withAuthedVmApiRoute, - vmRequiresProResponse, + resolveVmProvisioningAccountScope, } from "../../../../services/vms/routeHelpers"; import { setSpanAttributes } from "../../../../services/telemetry"; -import { - isVmBillingTeamResolutionError, - isVmProGateBlocked, - resolveVmEntitlements, -} from "../../../../services/vms/entitlements"; import { restoreVm, runVmWorkflow } from "../../../../services/vms/workflows"; import { VmTimingRecorder } from "../../../../services/vms/timings"; import { authProviderErrorResponse } from "../../../../services/vms/authErrors"; @@ -71,10 +65,26 @@ export async function POST(request: Request): Promise { } const providerResult = providerField(body); if (!providerResult.ok) return providerResult.response; + let user: AuthedUser = initialUser; + const requestedBillingTeamId = stringField(body, "billingTeamId") ?? stringField(body, "teamId") ?? requestedVmTeamIdFromRequest(request); + if (requestedBillingTeamId && !user.teamIds.includes(requestedBillingTeamId)) { + let refreshedUser: AuthedUser | null; + try { + refreshedUser = await verifyRequest(request, { requestedTeamId: requestedBillingTeamId }); + } catch (error) { + return authProviderErrorResponse(error, "/api/vm.restore.team-auth"); + } + if (!refreshedUser) return unauthorized(); + user = refreshedUser; + } + const account = resolveVmProvisioningAccountScope(user, request, { requestedBillingTeamId }); + if (!account.ok) return account.response; + const entitlements = account.entitlements; + + // Restore provisions a brand-new machine on `provider`; check the + // environment kill switch only after the paid-plan boundary so a free + // caller cannot be diverted into provider/config work first. const provider = providerResult.provider ?? defaultProviderId(); - // Kill-switch parity with POST /api/vm: restore provisions a brand-new - // machine on `provider`, so it must refuse before any team refresh or - // workflow work when creation is disabled. try { assertVmCreateEnabled(provider); } catch (err) { @@ -91,30 +101,6 @@ export async function POST(request: Request): Promise { } throw err; } - let user: AuthedUser = initialUser; - const requestedBillingTeamId = stringField(body, "billingTeamId") ?? stringField(body, "teamId") ?? requestedVmTeamIdFromRequest(request); - if (requestedBillingTeamId && !user.teamIds.includes(requestedBillingTeamId)) { - let refreshedUser: AuthedUser | null; - try { - refreshedUser = await verifyRequest(request, { requestedTeamId: requestedBillingTeamId }); - } catch (error) { - return authProviderErrorResponse(error, "/api/vm.restore.team-auth"); - } - if (!refreshedUser) return unauthorized(); - user = refreshedUser; - } - let entitlements; - try { - entitlements = resolveVmEntitlements(user, process.env, { - requestedBillingTeamId, - }); - } catch (err) { - if (isVmBillingTeamResolutionError(err)) return vmBillingTeamErrorResponse(err); - throw err; - } - if (isVmProGateBlocked(entitlements)) { - return vmRequiresProResponse(); - } const idempotencyKey = idempotencyKeyFromRequest(request); setSpanAttributes(span, { "cmux.snapshot.id": snapshotId, diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index 60cdb9eab9c2..03dfd9219665 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -26,7 +26,6 @@ import { defaultMemoryMbForPlan, isPaidVmPlan, isVmBillingTeamResolutionError, - isVmProGateBlocked, maxMemoryMbForPlan, resolveVmEntitlements, vmFreeAccessWindowDays, @@ -53,7 +52,7 @@ import { vmWorkflowErrorResponse, withAuthedVmApiRoute, vmActiveLimitExceededResponse, - vmRequiresProResponse, + resolveVmProvisioningAccountScope, } from "../../../services/vms/routeHelpers"; import { vmRequestLocale } from "../../../services/vms/vmErrorMessages"; import { captureVmProvisionOutcome } from "../../../services/vms/observability"; @@ -303,44 +302,6 @@ export async function POST(request: Request): Promise { provider: candidate.provider as ProviderId | undefined, billingTeamId: typeof bodyBillingTeamId === "string" ? bodyBillingTeamId.trim() : undefined, }; - // An explicit manifest image names its own provider: the CLI sends - // provider-specific image ids without a provider field, and the - // deployment default must not reroute them under the wrong provider. - const provider = body.provider ?? inferVmProviderForImage(body.image) ?? defaultProviderId(); - let imageSelection; - try { - assertVmCreateEnabled(provider); - imageSelection = resolveVmImage(provider, body.image, process.env, { kind: body.kind }); - } catch (err) { - if (isVmCreateDisabledError(err)) { - return vmErrorResponse({ - error: "vm_create_disabled", - status: 503, - message: "Cloud VM creation is disabled for this environment.", - action: "Ask an admin to enable Cloud VM creation, then retry.", - reason: "Cloud VM creation is disabled.", - }); - } - if (isVmImageConfigError(err)) { - const described = reportVmImageConfigError(err); - return vmErrorResponse({ - error: "vm_image_config_error", - status: 503, - message: described.message, - action: described.action, - reason: "Cloud VM image configuration is unavailable.", - details: described.details, - diagnostics: { - provider, - image: err.image, - envVar: err.envVar, - configReason: err.reason, - }, - }); - } - throw err; - } - const image = imageSelection.image; // Idempotency-Key is standard HTTP; we also accept x-cmux-idempotency-key for CLI // callers that don't know about RFC-style keys. Trim + clamp to a reasonable length // so we don't store unbounded idempotency metadata. @@ -350,13 +311,6 @@ export async function POST(request: Request): Promise { "" ).trim(); const idempotencyKey = rawKey ? rawKey.slice(0, 128) : undefined; - setSpanAttributes(span, { - "cmux.vm.provider": provider, - "cmux.vm.image_set": image.length > 0, - "cmux.vm.image_version": imageSelection.imageVersion, - "cmux.vm.image_manifest": !!imageSelection.manifestEntry, - "cmux.idempotency_key_set": !!idempotencyKey, - }); const requestedBillingTeamId = body.billingTeamId || requestedVmTeamIdFromRequest(request); if (requestedBillingTeamId && !user.teamIds.includes(requestedBillingTeamId)) { @@ -393,19 +347,11 @@ export async function POST(request: Request): Promise { } catch (err) { console.error("[VM] Pro plan reconcile failed", err); } - let entitlements; - try { - entitlements = measureVmSync(timing, "entitlements", () => - resolveVmEntitlements(user, process.env, { - requestedBillingTeamId, - }) - ); - } catch (err) { - if (isVmBillingTeamResolutionError(err)) { - return billingTeamErrorResponse(err); - } - throw err; - } + const account = measureVmSync(timing, "entitlements", () => + resolveVmProvisioningAccountScope(user, request, { requestedBillingTeamId }) + ); + if (!account.ok) return account.response; + const entitlements = account.entitlements; setSpanAttributes(span, { "cmux.billing.team_id_set": !!entitlements.billingTeamId, "cmux.billing.customer_type": entitlements.billingCustomerType, @@ -414,10 +360,6 @@ export async function POST(request: Request): Promise { "cmux.vm.max_active": entitlements.maxActiveVms, }); - if (isVmProGateBlocked(entitlements)) { - return vmRequiresProResponse(); - } - const maxMemoryMb = maxMemoryMbForPlan(entitlements.planId, process.env); const memoryMb = candidate.memoryMb === undefined @@ -437,6 +379,55 @@ export async function POST(request: Request): Promise { "cmux.vm.max_memory_mb": maxMemoryMb, }); + // Resolve provider/image only after the paid-plan boundary. A free or + // unknown plan must receive `vm_requires_pro` without consulting + // provider configuration, image manifests, or provider SDKs. + // An explicit manifest image names its own provider: the CLI sends + // provider-specific image ids without a provider field, and the + // deployment default must not reroute them under the wrong provider. + const provider = body.provider ?? inferVmProviderForImage(body.image) ?? defaultProviderId(); + let imageSelection; + try { + assertVmCreateEnabled(provider); + imageSelection = resolveVmImage(provider, body.image, process.env, { kind: body.kind }); + } catch (err) { + if (isVmCreateDisabledError(err)) { + return vmErrorResponse({ + error: "vm_create_disabled", + status: 503, + message: "Cloud VM creation is disabled for this environment.", + action: "Ask an admin to enable Cloud VM creation, then retry.", + reason: "Cloud VM creation is disabled.", + }); + } + if (isVmImageConfigError(err)) { + const described = reportVmImageConfigError(err); + return vmErrorResponse({ + error: "vm_image_config_error", + status: 503, + message: described.message, + action: described.action, + reason: "Cloud VM image configuration is unavailable.", + details: described.details, + diagnostics: { + provider, + image: err.image, + envVar: err.envVar, + configReason: err.reason, + }, + }); + } + throw err; + } + const image = imageSelection.image; + setSpanAttributes(span, { + "cmux.vm.provider": provider, + "cmux.vm.image_set": image.length > 0, + "cmux.vm.image_version": imageSelection.imageVersion, + "cmux.vm.image_manifest": !!imageSelection.manifestEntry, + "cmux.idempotency_key_set": !!idempotencyKey, + }); + // Wire the machine to coderouter: mint a per-machine route token and // hand it over as create-time env (the baked image materializes agent // configs from it). Best-effort: a coderouter outage or entitlement diff --git a/web/app/env.ts b/web/app/env.ts index 7542fa8b9da1..e35c3ecaea48 100644 --- a/web/app/env.ts +++ b/web/app/env.ts @@ -222,6 +222,12 @@ export const env = createEnv({ CMUX_TESTFLIGHT_APP_ID: z.string().min(1).optional(), CMUX_PRO_TESTFLIGHT_GROUP_ID: z.string().min(1).optional(), SENTRY_DSN: z.string().url().optional(), + // Cloud VM provisioning is paid-plan-only by default. The allow switch is + // intentionally opt-in for controlled demos/rollbacks; the legacy require + // flag remains accepted by the entitlement layer for migration parity. + CMUX_VM_ALLOW_FREE_PROVISIONING: z.string().optional(), + CMUX_VM_REQUIRE_PRO: z.string().optional(), + CMUX_VM_DEFAULT_PLAN: z.string().optional(), // Hosted coderouter requires an active personal cmux Pro subscription. // Self-hosted deployments leave this unset (or set it to "0"). CODEROUTER_HOSTED_PRO_REQUIRED: requireVercelProductionValue( @@ -391,6 +397,9 @@ export const env = createEnv({ CMUX_TESTFLIGHT_APP_ID: trimEnv(process.env.CMUX_TESTFLIGHT_APP_ID), CMUX_PRO_TESTFLIGHT_GROUP_ID: trimEnv(process.env.CMUX_PRO_TESTFLIGHT_GROUP_ID), SENTRY_DSN: trimEnv(process.env.SENTRY_DSN), + CMUX_VM_ALLOW_FREE_PROVISIONING: trimEnv(process.env.CMUX_VM_ALLOW_FREE_PROVISIONING), + CMUX_VM_REQUIRE_PRO: trimEnv(process.env.CMUX_VM_REQUIRE_PRO), + CMUX_VM_DEFAULT_PLAN: trimEnv(process.env.CMUX_VM_DEFAULT_PLAN), CODEROUTER_HOSTED_PRO_REQUIRED: trimEnv( process.env.CODEROUTER_HOSTED_PRO_REQUIRED, ), diff --git a/web/scripts/cloud-vm/projects.mjs b/web/scripts/cloud-vm/projects.mjs index c951ceac32d0..12e0a5db2e22 100755 --- a/web/scripts/cloud-vm/projects.mjs +++ b/web/scripts/cloud-vm/projects.mjs @@ -67,6 +67,9 @@ export const recommendedRuntimeEnvKeys = [ // Kill switches are off-only: unset means enabled, so requiring presence // would fail a healthy deployment. Recommended for explicitness (the other // provider flags are set in prod). + // The paid-plan gate is the inverse: unset is the safe value, and setting + // this key is an exceptional, audited decision to allow free provisioning. + "CMUX_VM_ALLOW_FREE_PROVISIONING", "CMUX_VM_BLAXEL_ENABLED", "CMUX_DB_SSL_REJECT_UNAUTHORIZED", "OTEL_EXPORTER_OTLP_ENDPOINT", diff --git a/web/services/vms/README.md b/web/services/vms/README.md index 83fbbf911a17..a987b765d33a 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -188,6 +188,13 @@ Set these Vercel environment variables per production/staging environment: - `CMUX_DB_SSL_REJECT_UNAUTHORIZED`, optional. Leave unset for the current Vercel Marketplace Aurora databases so Node uses its default trust store. - `CMUX_VM_CREATE_ENABLED`, global create kill switch. Set `0` to block new paid creates while keeping list, attach, and delete available. +- `CMUX_VM_ALLOW_FREE_PROVISIONING`, explicit opt-out of the paid-plan Cloud VM gate. Leave unset + (or set to `0`) in every shared environment; set to `1` only for a deliberate demo/rollback. When + enabled, `CMUX_VM_FREE_MAX_ACTIVE_VMS` and the plan-specific free limit are honored again. +- `CMUX_VM_REQUIRE_PRO`, legacy compatibility spelling for the paid-plan gate. Unset now means the + gate is **on**. `0`/`false`/`off` is treated as the old permissive escape hatch only when + `CMUX_VM_ALLOW_FREE_PROVISIONING` is absent; prefer the clearly named allow switch for new + deployments. - `CMUX_VM_E2B_ENABLED`, per-provider E2B create kill switch. - `CMUX_VM_FREESTYLE_ENABLED`, per-provider Freestyle create kill switch. - `CMUX_VM_DAYTONA_ENABLED`, per-provider Daytona create kill switch. @@ -199,6 +206,9 @@ Set these Vercel environment variables per production/staging environment: - `FREESTYLE_SANDBOX_SNAPSHOT`, Freestyle snapshot id. - `DAYTONA_SANDBOX_SNAPSHOT`, Daytona snapshot name for WebSocket PTY sandboxes. - `CMUX_VM_DEFAULT_PROVIDER`, `blaxel`, `freestyle`, `e2b`, or `daytona` (defaults to `blaxel`). +- `CMUX_VM_DEFAULT_PLAN`, optional fallback for accounts without plan metadata. It defaults to `free`; + paid values are ignored unless `CMUX_VM_ALLOW_FREE_PROVISIONING=1`, so deployment configuration + cannot silently grant every unclassified account a paid entitlement. - `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID`, optional Stack Auth team item used as the free-plan create-credit bucket. Leave unset to skip free-plan create-credit accounting; set to `none`, `disabled`, `off`, or `false` to explicitly opt out. - `CMUX_VM_PLAN_FREE_CREATE_CREDIT_COST`, optional free-plan per-create cost. Defaults to `1`. - `CMUX_VM_PLAN_FREE_INITIAL_CREATE_CREDITS`, optional first-use seed for the free-plan Stack Auth create-credit item. Defaults to `20`. @@ -207,7 +217,7 @@ Set these Vercel environment variables per production/staging environment: - `CMUX_VM_CREATE_CREDIT_COST_E2B`, optional provider-specific override. - `CMUX_VM_CREATE_CREDIT_COST_FREESTYLE`, optional provider-specific override. - `CMUX_VM_CREATE_CREDIT_COST_DAYTONA`, optional provider-specific override. -- `CMUX_VM_FREE_MAX_ACTIVE_VMS`, default `0`. +- `CMUX_VM_FREE_MAX_ACTIVE_VMS`, default `0` and ignored while the paid-plan gate is enforced. - `CMUX_VM_PAID_MAX_ACTIVE_VMS`, default `5`. - Stack Auth environment variables. - Axiom/OpenTelemetry exporter variables. @@ -388,7 +398,7 @@ Keep Freestyle/E2B enabled only when deliberately selecting them as rollback pro The usage ledger is in Postgres. VM create pricing gates can use Stack Auth payment items, but free-plan create credits are opt-in. Configure `CMUX_VM_PLAN_FREE_CREATE_CREDIT_ITEM_ID` only when the free plan should consume a prepaid create-credit bucket. When enabled, the create workflow records a one-time local grant row, seeds the configured Stack Auth item credits once per billing team, reserves one create credit only for a newly inserted row, calls the provider, and refunds the credit if provisioning fails before a usable VM exists. -Plan limits are team-based. Stack Auth personal teams should stay enabled for both dev/staging and production projects (`createTeamOnSignUp` / `teams.createPersonalTeamOnSignUp`). New VM rows store `billing_team_id` and `billing_plan_id`; the free plan allows zero active VMs by default (`CMUX_VM_FREE_MAX_ACTIVE_VMS`); paid plans default to five (`CMUX_VM_PAID_MAX_ACTIVE_VMS`). Destroyed VMs do not count against the active limit; pausing does not free quota on the production provider. Paid plan activation should write a readable plan id such as `pro` into Stack Auth team read-only metadata (`cmuxVmPlan`) or equivalent billing sync metadata, then configure the matching `CMUX_VM_PLAN__MAX_ACTIVE_VMS` env var. Paid plans only consume Stack Auth create credits when `CMUX_VM_PLAN__CREATE_CREDIT_ITEM_ID` or the global `CMUX_VM_CREATE_CREDIT_ITEM_ID` is configured. +Plan limits are team-based. Stack Auth personal teams should stay enabled for both dev/staging and production projects (`createTeamOnSignUp` / `teams.createPersonalTeamOnSignUp`). New VM rows store `billing_team_id` and `billing_plan_id`; the free plan allows zero active VMs by default and remains at zero regardless of stale free-limit env values while the paid-plan gate is on. A deliberate `CMUX_VM_ALLOW_FREE_PROVISIONING=1` escape hatch re-enables the configured free allowance for local demos or a controlled rollback; paid plans default to five (`CMUX_VM_PAID_MAX_ACTIVE_VMS`). Destroyed VMs do not count against the active limit; pausing does not free quota on the production provider. Paid plan activation should write a readable plan id such as `pro` into Stack Auth team read-only metadata (`cmuxVmPlan`) or equivalent billing sync metadata, then configure the matching `CMUX_VM_PLAN__MAX_ACTIVE_VMS` env var. Paid plans only consume Stack Auth create credits when `CMUX_VM_PLAN__CREATE_CREDIT_ITEM_ID` or the global `CMUX_VM_CREATE_CREDIT_ITEM_ID` is configured. ### The free limit is the paywall moment diff --git a/web/services/vms/entitlements.ts b/web/services/vms/entitlements.ts index d6b1433f1f91..7df938a2feec 100644 --- a/web/services/vms/entitlements.ts +++ b/web/services/vms/entitlements.ts @@ -39,7 +39,16 @@ export function resolveVmEntitlements( options: VmEntitlementOptions = {}, ): VmEntitlements { const billing = resolveBillingContext(user, options); - const planId = normalizedPlanId(billing.billingPlanId ?? env.CMUX_VM_DEFAULT_PLAN ?? "free"); + const configuredDefaultPlan = env.CMUX_VM_DEFAULT_PLAN; + // A deployment-wide default is useful for local/demo fixtures, but it must + // never grant a paid entitlement to an account with no billing metadata in + // the normal fail-closed mode. Reusing the same explicit escape hatch keeps + // this fallback from becoming a second permissive configuration path. + const defaultPlan = configuredDefaultPlan && + (isVmFreeProvisioningAllowed(env) || !isPaidVmPlan(configuredDefaultPlan)) + ? configuredDefaultPlan + : "free"; + const planId = normalizedPlanId(billing.billingPlanId ?? defaultPlan); return { planId, billingCustomerType: billing.billingCustomerType, @@ -197,15 +206,38 @@ export function isPaidVmPlan(planId: string): boolean { } /** - * Whether Cloud VM provisioning is gated behind a paid plan. Ships dark: the - * gate is OFF unless CMUX_VM_REQUIRE_PRO is explicitly truthy, so free users - * keep provisioning until product flips the env to launch (mirrors the - * CMUX_VM_CREATE_ENABLED opt-out convention, inverted to opt-in). + * Whether Cloud VM provisioning is gated behind a paid plan. + * + * The safe default is enforced. `CMUX_VM_ALLOW_FREE_PROVISIONING=1` is an + * explicit operator escape hatch for demos or a controlled rollback. The + * historical `CMUX_VM_REQUIRE_PRO=0` spelling remains a compatibility alias + * when the new switch is absent; every other unset, malformed, or truthy + * value keeps the gate closed to free plans. */ export function isVmProGateEnforced( env: Record = process.env, ): boolean { - return isVmRequireProFlag(env.CMUX_VM_REQUIRE_PRO); + return !isVmFreeProvisioningAllowed(env); +} + +/** + * Whether an operator has explicitly opted into free Cloud VM provisioning. + * Keep this as the shared policy predicate so the gate and free active limit + * cannot drift into different permissive states. + */ +export function isVmFreeProvisioningAllowed( + env: Record = process.env, +): boolean { + // The new name is authoritative when present. A value must be explicitly + // truthy; typos and explicit false values fail closed. + if (env.CMUX_VM_ALLOW_FREE_PROVISIONING !== undefined) { + return isVmTruthyFlag(env.CMUX_VM_ALLOW_FREE_PROVISIONING); + } + + // Preserve the old opt-in gate's false values as a migration alias. An + // absent or malformed legacy value now fails closed instead of shipping dark. + const legacy = env.CMUX_VM_REQUIRE_PRO; + return legacy !== undefined && isVmFalseFlag(legacy); } /** @@ -220,7 +252,7 @@ export function isVmProGateBlocked( return isVmProGateEnforced(env) && !isPaidVmPlan(entitlements.planId); } -function isVmRequireProFlag(value: string | undefined): boolean { +function isVmTruthyFlag(value: string | undefined): boolean { if (value === undefined) return false; switch (value.trim().toLowerCase()) { case "1": @@ -234,20 +266,35 @@ function isVmRequireProFlag(value: string | undefined): boolean { } } +function isVmFalseFlag(value: string | undefined): boolean { + if (value === undefined) return false; + switch (value.trim().toLowerCase()) { + case "0": + case "false": + case "no": + case "off": + case "disabled": + return true; + default: + return false; + } +} + function activeVmLimitForPlan(planId: string, env: Record): number { const planKey = planId.replace(/[^a-zA-Z0-9]/g, "_").toUpperCase(); - const specific = env[`CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`]; - if (specific?.trim()) return positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`); - - if (planId === "free") { - // Cloud machines are a paid feature: free plans start at zero. Every - // create is the upgrade prompt (vmActiveLimitExceededResponse renders the - // paywall variant for unpaid plans, and the app's New Machine button opens - // the Pro flow at the ceiling). CMUX_VM_FREE_MAX_ACTIVE_VMS re-opens a - // demo allowance without a deploy. + if (!isPaidVmPlan(planId)) { + // Cloud machines are a paid feature. Keep every non-paid/unknown plan at + // zero unless the same explicit escape hatch that disables the Pro gate is + // set; this prevents a stale `CMUX_VM_FREE_MAX_ACTIVE_VMS` (or a plan- + // specific override) from reopening provisioning by configuration drift. + if (!isVmFreeProvisioningAllowed(env)) return 0; + const specific = env[`CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`]; + if (specific?.trim()) return positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`); return nonNegativeInteger(env.CMUX_VM_FREE_MAX_ACTIVE_VMS ?? "0", "CMUX_VM_FREE_MAX_ACTIVE_VMS"); } + const specific = env[`CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`]; + if (specific?.trim()) return positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_MAX_ACTIVE_VMS`); return positiveInteger(env.CMUX_VM_PAID_MAX_ACTIVE_VMS ?? "5", "CMUX_VM_PAID_MAX_ACTIVE_VMS"); } diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index fee36aaf9a1c..315df2c27346 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -9,6 +9,7 @@ import { import { isPaidVmPlan, isVmBillingTeamResolutionError, + isVmProGateBlocked, maxActiveVmsForPlan, resolveVmEntitlements, type VmEntitlements, @@ -271,11 +272,45 @@ export type VmRouteAccountScope = readonly response: Response; }; +type VmProvisioningScopeOptions = { + readonly requestedBillingTeamId?: string | null; +}; + +/** + * Resolve the account scope for a route that can allocate a new machine. + * + * Provisioning routes must use this helper instead of resolving entitlements + * and checking the Pro gate independently. Keeping the account lookup and + * policy decision together makes a newly added provisioning route fail closed + * by construction while management routes can continue to use + * `resolveVmRouteAccountScope` without a paywall. + */ +export function resolveVmProvisioningAccountScope( + user: AuthedUser, + request: Request, + options: VmProvisioningScopeOptions = {}, +): VmRouteAccountScope { + const scope = resolveVmAccountScope(user, request, options); + if (!scope.ok) return scope; + if (isVmProGateBlocked(scope.entitlements)) { + return { ok: false, response: vmRequiresProResponse() }; + } + return scope; +} + export function resolveVmRouteAccountScope( user: AuthedUser, request: Request, ): VmRouteAccountScope { - const requestedBillingTeamId = requestedVmTeamIdFromRequest(request); + return resolveVmAccountScope(user, request); +} + +function resolveVmAccountScope( + user: AuthedUser, + request: Request, + options: VmProvisioningScopeOptions = {}, +): VmRouteAccountScope { + const requestedBillingTeamId = options.requestedBillingTeamId ?? requestedVmTeamIdFromRequest(request); try { return { ok: true, @@ -306,21 +341,21 @@ export function vmBillingTeamErrorResponse(err: { action: err.code === "vm_billing_team_not_found" ? "Switch to a team you belong to, or run `cmux auth login` again and retry with the correct team id." : "Select a team in cmux, or pass the team id with `X-Cmux-Team-Id`.", - reason: err.message, }); } +const VM_UPGRADE_URL = "https://cmux.com/pricing"; + export function vmRequiresProResponse(): Response { return vmErrorResponse({ error: "vm_requires_pro", status: 402, message: "Cloud VMs require a cmux Pro plan.", - action: "Upgrade to cmux Pro at https://cmux.com/pricing to create Cloud VMs.", + action: `Upgrade to cmux Pro at ${VM_UPGRADE_URL} to create Cloud VMs.`, + extra: { upgradeRequired: true, upgradeUrl: VM_UPGRADE_URL }, }); } -const VM_UPGRADE_URL = "https://cmux.com/pricing"; - /** * One response for every provisioning verb that hits the active-VM limit. On a free plan the * limit is the paywall moment: the message sells the upgrade (Pro removes the cap and bills by From ad678784bd58cfa1eef6817eb9989799cb832126 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 31 Aug 2026 23:26:12 -0700 Subject: [PATCH 3/8] test: cover localized vm_requires_pro copy and a failing free-provisioning env audit Review follow-ups for #11332. These fail until the next commit: the audit script only listed CMUX_VM_ALLOW_FREE_PROVISIONING for presence and could not fail on a permissive value, and vm_requires_pro returned hardcoded English regardless of the request locale. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01886xVcepPfsLRFCrFXLh1M --- web/tests/cloud-vm-env-audit.test.ts | 70 ++++++++++++++++++++++++++-- web/tests/vm-pro-gate.test.ts | 50 ++++++++++++++++++++ web/tests/vm-route-auth.test.ts | 20 ++++++++ 3 files changed, 137 insertions(+), 3 deletions(-) diff --git a/web/tests/cloud-vm-env-audit.test.ts b/web/tests/cloud-vm-env-audit.test.ts index fae51c7c985a..3754d575d153 100644 --- a/web/tests/cloud-vm-env-audit.test.ts +++ b/web/tests/cloud-vm-env-audit.test.ts @@ -8,11 +8,17 @@ import { auditProviderReadiness, CODE_DEFAULT_PROVIDER, } from "../scripts/cloud-vm/defaultProviderAudit.mjs"; +import { + auditFreeProvisioningOverride, + freeProvisioningOverrideEnvKeys, + isFreeProvisioningAllowed, +} from "../scripts/cloud-vm/freeProvisioningAudit.mjs"; import { recommendedRuntimeEnvKeys, requiredRuntimeEnvKeys, } from "../scripts/cloud-vm/projects.mjs"; import { defaultProviderId } from "../services/vms/drivers"; +import { isVmFreeProvisioningAllowed } from "../services/vms/entitlements"; type Manifest = { images: Array<{ @@ -205,8 +211,66 @@ describe("required runtime env keys cover the production provider path", () => { } }); - test("the free-provisioning escape hatch is visible but never required", () => { - expect(recommendedRuntimeEnvKeys).toContain("CMUX_VM_ALLOW_FREE_PROVISIONING"); - expect(requiredRuntimeEnvKeys).not.toContain("CMUX_VM_ALLOW_FREE_PROVISIONING"); + test("the free-provisioning escape hatch is never required or recommended", () => { + // Unset is the safe value; listing it for presence would nudge operators + // into setting it. Its VALUE is audited instead (see below). + for (const key of freeProvisioningOverrideEnvKeys) { + expect(requiredRuntimeEnvKeys).not.toContain(key); + expect(recommendedRuntimeEnvKeys).not.toContain(key); + } + }); +}); + +describe("free-provisioning override audit", () => { + type Audit = { present: string[]; allowed: boolean; problems: string[] }; + + test("an unset override is clean", () => { + const result = auditFreeProvisioningOverride({}) as Audit; + expect(result).toEqual({ present: [], allowed: false, problems: [] }); + }); + + test("an explicit off value is clean", () => { + for (const env of [ + { CMUX_VM_ALLOW_FREE_PROVISIONING: "0" }, + { CMUX_VM_ALLOW_FREE_PROVISIONING: "false" }, + { CMUX_VM_REQUIRE_PRO: "1" }, + // The new switch wins over a stale permissive legacy value. + { CMUX_VM_ALLOW_FREE_PROVISIONING: "0", CMUX_VM_REQUIRE_PRO: "0" }, + ]) { + expect((auditFreeProvisioningOverride(env) as Audit).problems).toEqual([]); + } + }); + + test("a permissive value fails the audit, not just a note", () => { + const result = auditFreeProvisioningOverride({ CMUX_VM_ALLOW_FREE_PROVISIONING: "1" }) as Audit; + expect(result.allowed).toBe(true); + expect(result.problems.join("\n")).toContain("free Cloud VM provisioning is enabled"); + expect(result.problems.join("\n")).toContain("CMUX_VM_ALLOW_FREE_PROVISIONING=1"); + }); + + test("a lone legacy CMUX_VM_REQUIRE_PRO=0 is the same outage", () => { + const result = auditFreeProvisioningOverride({ CMUX_VM_REQUIRE_PRO: "0" }) as Audit; + expect(result.allowed).toBe(true); + expect(result.problems.join("\n")).toContain("legacy CMUX_VM_REQUIRE_PRO=0"); + }); + + test("a Sensitive override value cannot be audited and fails", () => { + const result = auditFreeProvisioningOverride({ CMUX_VM_ALLOW_FREE_PROVISIONING: "[SENSITIVE]" }) as Audit; + expect(result.allowed).toBe(false); + expect(result.problems.join("\n")).toContain("cannot be audited"); + }); + + test("the audit mirrors the runtime gate decision exactly", () => { + // The .mjs cannot import the TypeScript runtime, so this pins the copy of + // the flag semantics to the real predicate across every accepted spelling. + const values = [undefined, "", "1", "0", "true", "false", "yes", "no", "on", "off", "enabled", "disabled", "TRUE ", " Off", "maybe"]; + for (const allow of values) { + for (const legacy of values) { + const env: Record = {}; + if (allow !== undefined) env.CMUX_VM_ALLOW_FREE_PROVISIONING = allow; + if (legacy !== undefined) env.CMUX_VM_REQUIRE_PRO = legacy; + expect(isFreeProvisioningAllowed(env)).toBe(isVmFreeProvisioningAllowed(env)); + } + } }); }); diff --git a/web/tests/vm-pro-gate.test.ts b/web/tests/vm-pro-gate.test.ts index 42949d565bb9..70aa0313a9e4 100644 --- a/web/tests/vm-pro-gate.test.ts +++ b/web/tests/vm-pro-gate.test.ts @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test"; +import { locales } from "../i18n/routing"; import { isPaidVmPlan, isVmFreeProvisioningAllowed, isVmProGateBlocked, isVmProGateEnforced, } from "../services/vms/entitlements"; +import { vmRequiresProResponse } from "../services/vms/routeHelpers"; const ent = (planId: string) => ({ planId }); @@ -62,3 +64,51 @@ describe("Cloud VM Pro gate", () => { expect(isVmProGateBlocked(ent("founders"), env)).toBe(false); }); }); + +describe("vm_requires_pro response copy", () => { + test("defaults to English and keeps the machine-readable upgrade fields", async () => { + const response = await vmRequiresProResponse(); + expect(response.status).toBe(402); + const payload = await response.json() as Record; + expect(payload).toMatchObject({ + error: "vm_requires_pro", + message: "Cloud VMs require a cmux Pro plan.", + action: "Upgrade to cmux Pro at https://cmux.com/pricing to create Cloud VMs.", + upgradeRequired: true, + upgradeUrl: "https://cmux.com/pricing", + }); + }); + + test("resolves the upgrade instruction from the requested locale", async () => { + const payload = await (await vmRequiresProResponse("ja")).json() as Record; + expect(payload.message).toBe("Cloud VM を利用するには cmux Pro プランが必要です。"); + expect(String(payload.action)).toContain("https://cmux.com/pricing"); + expect(String(payload.action)).toContain("cmux Pro にアップグレード"); + // Clients key off these, never the prose. + expect(payload).toMatchObject({ error: "vm_requires_pro", upgradeRequired: true }); + }); + + test("ships translated copy with the upgrade URL placeholder in every locale catalog", async () => { + for (const locale of locales) { + const messages = (await import(`../messages/${locale}.json`)).default as { + vmErrors: { requiresPro?: { message?: string; action?: string } }; + }; + const copy = messages.vmErrors.requiresPro; + const payload = await (await vmRequiresProResponse(locale)).json() as { action: string }; + // Keyed by locale so a failure names the catalog that is missing or broken. + expect({ + locale, + hasMessage: Boolean(copy?.message), + actionHasPlaceholder: copy?.action?.includes("{upgradeUrl}") ?? false, + renderedHasUrl: payload.action.includes("https://cmux.com/pricing"), + renderedHasRawPlaceholder: payload.action.includes("{upgradeUrl}"), + }).toEqual({ + locale, + hasMessage: true, + actionHasPlaceholder: true, + renderedHasUrl: true, + renderedHasRawPlaceholder: false, + }); + } + }); +}); diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 6565c1ef9fcf..8be8722c3370 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -650,6 +650,26 @@ describe("VM REST auth", () => { expect(createVm).not.toHaveBeenCalled(); }); + test("the paid-plan gate answers in the client's locale", async () => { + getUser.mockResolvedValue(freePlanStackUser()); + + const response = await POST( + new Request("https://cmux.test/api/vm", { + method: "POST", + headers: { origin: "https://cmux.test", "x-next-intl-locale": "ja" }, + body: JSON.stringify({ provider: "freestyle", image: "snapshot-test" }), + }), + ); + + expect(response.status).toBe(402); + const payload = await response.json() as { error: string; message: string; action: string; upgradeUrl: string }; + expect(payload.error).toBe("vm_requires_pro"); + expect(payload.message).toBe("Cloud VM を利用するには cmux Pro プランが必要です。"); + expect(payload.action).toContain("https://cmux.com/pricing"); + expect(payload.upgradeUrl).toBe("https://cmux.com/pricing"); + expect(createVm).not.toHaveBeenCalled(); + }); + test("an explicit free-provisioning switch reopens the configured demo allowance", async () => { process.env.CMUX_VM_ALLOW_FREE_PROVISIONING = "1"; process.env.CMUX_VM_FREE_MAX_ACTIVE_VMS = "5"; From c1a23061779f6607c5208638c0b00638c1d7d3c6 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 31 Aug 2026 23:26:12 -0700 Subject: [PATCH 4/8] vm: localize the Pro gate response and fail the env audit on free provisioning Address review findings on #11332: - vm_requires_pro copy now comes from the vmErrors.requiresPro catalog in all 20 locales; resolveVmProvisioningAccountScope is async and reads the request locale. upgradeUrl/upgradeRequired stay locale-free. - audit-vercel-env.mjs fails when CMUX_VM_ALLOW_FREE_PROVISIONING is permissive or a lone legacy CMUX_VM_REQUIRE_PRO=0 reopens free provisioning (freeProvisioningAudit.mjs mirrors the runtime predicate, pinned by a parity test). The key is no longer "recommended". - Settings' Cloud machines plan summary reuses MachinePlanSnapshot .isPaidPlanID so it agrees with the Machines panel on unknown plan ids. - The new paid-plan tests move into a Swift Testing suite. - Rollout checklist documents the legacy alias precedence. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01886xVcepPfsLRFCrFXLh1M --- Sources/HostSettingsActions.swift | 4 +- cmuxTests/MachinesPanelModelTests.swift | 46 ++++++++++----- docs/cloud-vm-backend-rollout-todo.md | 7 ++- web/app/api/vm/[id]/fork/route.ts | 2 +- web/app/api/vm/base/routeShared.ts | 2 +- web/app/api/vm/restore/route.ts | 2 +- web/app/api/vm/route.ts | 3 +- web/messages/ar.json | 4 ++ web/messages/bs.json | 4 ++ web/messages/da.json | 4 ++ web/messages/de.json | 4 ++ web/messages/en.json | 4 ++ web/messages/es.json | 4 ++ web/messages/fr.json | 4 ++ web/messages/it.json | 4 ++ web/messages/ja.json | 4 ++ web/messages/km.json | 4 ++ web/messages/ko.json | 4 ++ web/messages/no.json | 4 ++ web/messages/pl.json | 4 ++ web/messages/pt-BR.json | 4 ++ web/messages/ru.json | 4 ++ web/messages/th.json | 4 ++ web/messages/tr.json | 4 ++ web/messages/uk.json | 4 ++ web/messages/zh-CN.json | 4 ++ web/messages/zh-TW.json | 4 ++ web/scripts/cloud-vm/audit-vercel-env.mjs | 8 ++- .../cloud-vm/freeProvisioningAudit.mjs | 58 +++++++++++++++++++ web/scripts/cloud-vm/projects.mjs | 6 +- web/services/vms/routeHelpers.ts | 20 ++++--- web/services/vms/vmErrorMessages.ts | 22 +++++++ 32 files changed, 227 insertions(+), 33 deletions(-) create mode 100644 web/scripts/cloud-vm/freeProvisioningAudit.mjs diff --git a/Sources/HostSettingsActions.swift b/Sources/HostSettingsActions.swift index 01ff7e5cfc4f..25b17bd069a2 100644 --- a/Sources/HostSettingsActions.swift +++ b/Sources/HostSettingsActions.swift @@ -317,7 +317,9 @@ final class HostSettingsActions: SettingsHostActions { func cloudMachinesPlanSummary() async -> CloudMachinesPlanSummary? { guard let client = VMClient.shared else { return nil } guard let page = try? await client.listPage(), let limits = page.limits else { return nil } - let isPaid = limits.planId != "free" + // Same classifier as the Machines panel so Settings and the panel never + // disagree about an unknown plan id (both fail closed to "not paid"). + let isPaid = MachinePlanSnapshot.isPaidPlanID(limits.planId) let planLabel = isPaid ? limits.planId.capitalized : String(localized: "settings.cloudMachines.plan.free", defaultValue: "Free") diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index 842bf4e2fde8..547995ab3594 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -108,20 +108,6 @@ final class MachinesPanelModelTests: XCTestCase { XCTAssertEqual(paid?.isPaidPlan, true) } - func testOnlyProvisioningPlansArePaid() { - XCTAssertTrue(MachinePlanSnapshot.isPaidPlanID("pro")) - XCTAssertTrue(MachinePlanSnapshot.isPaidPlanID("TEAM")) - XCTAssertTrue(MachinePlanSnapshot.isPaidPlanID("founders")) - XCTAssertFalse(MachinePlanSnapshot.isPaidPlanID("free")) - XCTAssertFalse(MachinePlanSnapshot.isPaidPlanID("unknown")) - } - - func testRequiresProErrorIncludesUpgradePathWhenServerOmitsAction() { - let error = VMClientError.httpStatus(402, "{\"error\":\"vm_requires_pro\"}") - XCTAssertTrue(error.description.contains("https://cmux.com/pricing")) - XCTAssertTrue(error.description.contains("Upgrade to cmux Pro")) - } - func testMachinesModeIsRegisteredEverywhere() { XCTAssertTrue(RightSidebarMode.allCases.contains(.machines)) XCTAssertEqual(RightSidebarMode.from(cliArgument: "machines"), .machines) @@ -958,3 +944,35 @@ struct MachinesPanelListProblemTests { ) } } + +@Suite("Cloud machines paid-plan classification") +struct MachinesPanelPaidPlanTests { + @Test("Only plans the backend accepts for provisioning are paid", arguments: [ + ("pro", true), ("TEAM", true), ("founders", true), (" Pro\n", true), + ("free", false), ("", false), ("unknown", false), ("enterprise-unknown", false), + ]) + func onlyProvisioningPlansArePaid(planId: String, expected: Bool) { + #expect(MachinePlanSnapshot.isPaidPlanID(planId) == expected) + } + + @Test("A plan snapshot and the shared classifier agree") + func planSnapshotUsesSharedClassifier() { + let paid = MachineSnapshotBuilder.planSnapshot( + activeCount: 0, + limits: VMPlanLimits(maxActiveVms: 5, planId: "founders", freeAccessWindowDays: 0) + ) + #expect(paid?.isPaidPlan == true) + let unknown = MachineSnapshotBuilder.planSnapshot( + activeCount: 0, + limits: VMPlanLimits(maxActiveVms: 5, planId: "mystery", freeAccessWindowDays: 0) + ) + #expect(unknown?.isPaidPlan == false) + } + + @Test("vm_requires_pro without a server action still names the upgrade path") + func requiresProErrorIncludesUpgradePathWhenServerOmitsAction() { + let error = VMClientError.httpStatus(402, #"{"error":"vm_requires_pro"}"#) + #expect(error.description.contains("https://cmux.com/pricing")) + #expect(error.description.contains("Upgrade to cmux Pro")) + } +} diff --git a/docs/cloud-vm-backend-rollout-todo.md b/docs/cloud-vm-backend-rollout-todo.md index 306c844acc30..2c5c3d81fe09 100644 --- a/docs/cloud-vm-backend-rollout-todo.md +++ b/docs/cloud-vm-backend-rollout-todo.md @@ -114,8 +114,11 @@ These are already configured in Vercel for development, preview, and production: - [ ] Add runtime VM vars to the relevant `~/.secrets/cmuxterm*.env` file: - `CMUX_VM_DEFAULT_PROVIDER` - `CMUX_VM_CREATE_ENABLED` - - `CMUX_VM_ALLOW_FREE_PROVISIONING` (leave unset; paid-plan gate is the safe default) - - `CMUX_VM_REQUIRE_PRO` (legacy compatibility alias only) + - `CMUX_VM_ALLOW_FREE_PROVISIONING` (leave unset; the paid-plan gate is the safe default and + `audit-vercel-env.mjs` fails when a shared environment sets it to `1`/`true`/`yes`/`on`/`enabled`) + - `CMUX_VM_REQUIRE_PRO` (legacy compatibility alias only: `0`/`false`/`no`/`off`/`disabled` + enables free provisioning **only while** `CMUX_VM_ALLOW_FREE_PROVISIONING` is unset; any set + value of the new switch wins, and every other legacy value or unset keeps the gate on) - `CMUX_VM_E2B_ENABLED` - `CMUX_VM_FREESTYLE_ENABLED` - `E2B_CMUXD_WS_TEMPLATE` diff --git a/web/app/api/vm/[id]/fork/route.ts b/web/app/api/vm/[id]/fork/route.ts index 3fdeac0e194a..15fe367b5424 100644 --- a/web/app/api/vm/[id]/fork/route.ts +++ b/web/app/api/vm/[id]/fork/route.ts @@ -60,7 +60,7 @@ export async function POST( if (!refreshedUser) return unauthorized(); user = refreshedUser; } - const account = resolveVmProvisioningAccountScope(user, request, { requestedBillingTeamId }); + const account = await resolveVmProvisioningAccountScope(user, request, { requestedBillingTeamId }); if (!account.ok) return account.response; const entitlements = account.entitlements; const idempotencyKey = idempotencyKeyFromRequest(request); diff --git a/web/app/api/vm/base/routeShared.ts b/web/app/api/vm/base/routeShared.ts index 1a87094d99fa..8482049d18b6 100644 --- a/web/app/api/vm/base/routeShared.ts +++ b/web/app/api/vm/base/routeShared.ts @@ -50,7 +50,7 @@ export async function runBaseRoute(input: { if (!parsed.ok) return parsed.response; const requestedBillingTeamId = parsed.body.billingTeamId || requestedVmTeamIdFromRequest(input.request); - const account = resolveVmProvisioningAccountScope(input.user, input.request, { requestedBillingTeamId }); + const account = await resolveVmProvisioningAccountScope(input.user, input.request, { requestedBillingTeamId }); if (!account.ok) return account.response; const entitlements = account.entitlements; diff --git a/web/app/api/vm/restore/route.ts b/web/app/api/vm/restore/route.ts index 8073189f7c85..7aa9ce1b872e 100644 --- a/web/app/api/vm/restore/route.ts +++ b/web/app/api/vm/restore/route.ts @@ -77,7 +77,7 @@ export async function POST(request: Request): Promise { if (!refreshedUser) return unauthorized(); user = refreshedUser; } - const account = resolveVmProvisioningAccountScope(user, request, { requestedBillingTeamId }); + const account = await resolveVmProvisioningAccountScope(user, request, { requestedBillingTeamId }); if (!account.ok) return account.response; const entitlements = account.entitlements; diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index 03dfd9219665..5022565c58ae 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -64,7 +64,6 @@ import { import { recordSpanError, setSpanAttributes } from "../../../services/telemetry"; import { measureVmAsync, - measureVmSync, VmTimingRecorder, } from "../../../services/vms/timings"; import { authProviderErrorResponse } from "../../../services/vms/authErrors"; @@ -347,7 +346,7 @@ export async function POST(request: Request): Promise { } catch (err) { console.error("[VM] Pro plan reconcile failed", err); } - const account = measureVmSync(timing, "entitlements", () => + const account = await measureVmAsync(timing, "entitlements", () => resolveVmProvisioningAccountScope(user, request, { requestedBillingTeamId }) ); if (!account.ok) return account.response; diff --git a/web/messages/ar.json b/web/messages/ar.json index d8c4f42a128f..7d7772412783 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -15,6 +15,10 @@ "fork": "لا تعاود المحاولة. أنشئ VM جديدًا باستخدام `cmux vm new`.", "default": "لا تعاود المحاولة. شغّل `cmux vm ls` أو تواصل مع الدعم إذا اعتقدت أن هذا خطأ." } + }, + "requiresPro": { + "message": "تتطلب أجهزة Cloud VM خطة cmux Pro.", + "action": "قم بالترقية إلى cmux Pro عبر {upgradeUrl} لإنشاء أجهزة Cloud VM." } }, "billingRecovery": { diff --git a/web/messages/bs.json b/web/messages/bs.json index 44a217977c71..4d32ce25387e 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -15,6 +15,10 @@ "fork": "Ne pokušavajte ponovo. Kreirajte novi VM pomoću `cmux vm new`.", "default": "Ne pokušavajte ponovo. Pokrenite `cmux vm ls` ili kontaktirajte podršku ako mislite da je ovo greška." } + }, + "requiresPro": { + "message": "Cloud VM-ovi zahtijevaju cmux Pro plan.", + "action": "Nadogradite na cmux Pro na {upgradeUrl} da biste kreirali Cloud VM-ove." } }, "billingRecovery": { diff --git a/web/messages/da.json b/web/messages/da.json index 1f627aef85f0..3ffcecc16daf 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -15,6 +15,10 @@ "fork": "Prøv ikke igen. Opret en ny VM med `cmux vm new`.", "default": "Prøv ikke igen. Kør `cmux vm ls`, eller kontakt support, hvis du mener, at dette er forkert." } + }, + "requiresPro": { + "message": "Cloud VM'er kræver en cmux Pro-plan.", + "action": "Opgrader til cmux Pro på {upgradeUrl} for at oprette Cloud VM'er." } }, "billingRecovery": { diff --git a/web/messages/de.json b/web/messages/de.json index 3c650ca0fcad..12d7e0b3492f 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -15,6 +15,10 @@ "fork": "Nicht erneut versuchen. Erstellen Sie mit `cmux vm new` eine neue VM.", "default": "Nicht erneut versuchen. Führen Sie `cmux vm ls` aus oder wenden Sie sich an den Support, wenn dies nicht stimmt." } + }, + "requiresPro": { + "message": "Cloud-VMs erfordern einen cmux Pro-Plan.", + "action": "Führen Sie unter {upgradeUrl} ein Upgrade auf cmux Pro durch, um Cloud-VMs zu erstellen." } }, "billingRecovery": { diff --git a/web/messages/en.json b/web/messages/en.json index ae4076297846..0ee5458423b2 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -24,6 +24,10 @@ "fork": "Do not retry. Create a new VM with `cmux vm new`.", "default": "Do not retry. Run `cmux vm ls` to see your machines, or contact support if you believe this is wrong." } + }, + "requiresPro": { + "message": "Cloud VMs require a cmux Pro plan.", + "action": "Upgrade to cmux Pro at {upgradeUrl} to create Cloud VMs." } }, "ios": { diff --git a/web/messages/es.json b/web/messages/es.json index ba7be7245738..c582501eb8ee 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -15,6 +15,10 @@ "fork": "No vuelvas a intentarlo. Crea una nueva VM con `cmux vm new`.", "default": "No vuelvas a intentarlo. Ejecuta `cmux vm ls` o contacta con soporte si crees que esto es un error." } + }, + "requiresPro": { + "message": "Las Cloud VM requieren un plan cmux Pro.", + "action": "Actualiza a cmux Pro en {upgradeUrl} para crear Cloud VM." } }, "billingRecovery": { diff --git a/web/messages/fr.json b/web/messages/fr.json index ad2903a91c51..8b180751d790 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -15,6 +15,10 @@ "fork": "Ne réessayez pas. Créez une nouvelle VM avec `cmux vm new`.", "default": "Ne réessayez pas. Exécutez `cmux vm ls` ou contactez le support si cela vous semble incorrect." } + }, + "requiresPro": { + "message": "Les Cloud VM nécessitent un abonnement cmux Pro.", + "action": "Passez à cmux Pro sur {upgradeUrl} pour créer des Cloud VM." } }, "billingRecovery": { diff --git a/web/messages/it.json b/web/messages/it.json index 8d37b0b7f991..98b593e0edca 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -15,6 +15,10 @@ "fork": "Non riprovare. Crea una nuova VM con `cmux vm new`.", "default": "Non riprovare. Esegui `cmux vm ls` o contatta l’assistenza se ritieni che sia un errore." } + }, + "requiresPro": { + "message": "Le Cloud VM richiedono un piano cmux Pro.", + "action": "Passa a cmux Pro su {upgradeUrl} per creare Cloud VM." } }, "billingRecovery": { diff --git a/web/messages/ja.json b/web/messages/ja.json index 3e8ff3886ab7..a670723b4e67 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -24,6 +24,10 @@ "fork": "再試行しても解決しません。`cmux vm new` で新しい VM を作成してください。", "default": "再試行しても解決しません。`cmux vm ls` でマシンを確認するか、問題が続く場合はサポートに連絡してください。" } + }, + "requiresPro": { + "message": "Cloud VM を利用するには cmux Pro プランが必要です。", + "action": "{upgradeUrl} で cmux Pro にアップグレードすると Cloud VM を作成できます。" } }, "ios": { diff --git a/web/messages/km.json b/web/messages/km.json index 610751aed4a3..712d16574b77 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -15,6 +15,10 @@ "fork": "កុំ​ព្យាយាម​ម្តងទៀត។ បង្កើត VM ថ្មីដោយប្រើ `cmux vm new`។", "default": "កុំ​ព្យាយាម​ម្តងទៀត។ ដំណើរការ `cmux vm ls` ឬទាក់ទងជំនួយ ប្រសិនបើអ្នកគិតថានេះជាកំហុស។" } + }, + "requiresPro": { + "message": "Cloud VM តម្រូវឱ្យមានគម្រោង cmux Pro។", + "action": "ដំឡើងកំណែទៅ cmux Pro នៅ {upgradeUrl} ដើម្បីបង្កើត Cloud VM។" } }, "billingRecovery": { diff --git a/web/messages/ko.json b/web/messages/ko.json index dc065cf9fdfa..47c877b844c1 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -15,6 +15,10 @@ "fork": "다시 시도하지 마세요. `cmux vm new`로 새 VM을 만드세요.", "default": "다시 시도하지 마세요. `cmux vm ls`로 머신을 확인하거나 문제가 계속되면 지원팀에 문의하세요." } + }, + "requiresPro": { + "message": "Cloud VM을 사용하려면 cmux Pro 플랜이 필요합니다.", + "action": "{upgradeUrl}에서 cmux Pro로 업그레이드하면 Cloud VM을 만들 수 있습니다." } }, "billingRecovery": { diff --git a/web/messages/no.json b/web/messages/no.json index c3f4308bc00e..ef7bec25166e 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -15,6 +15,10 @@ "fork": "Ikke prøv på nytt. Opprett en ny VM med `cmux vm new`.", "default": "Ikke prøv på nytt. Kjør `cmux vm ls`, eller kontakt brukerstøtte hvis du mener dette er feil." } + }, + "requiresPro": { + "message": "Cloud VM-er krever en cmux Pro-plan.", + "action": "Oppgrader til cmux Pro på {upgradeUrl} for å opprette Cloud VM-er." } }, "billingRecovery": { diff --git a/web/messages/pl.json b/web/messages/pl.json index 9231fdb30ea9..a2ffa2ef51d5 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -15,6 +15,10 @@ "fork": "Nie ponawiaj próby. Utwórz nową VM za pomocą `cmux vm new`.", "default": "Nie ponawiaj próby. Uruchom `cmux vm ls` lub skontaktuj się z pomocą, jeśli uważasz, że to błąd." } + }, + "requiresPro": { + "message": "Cloud VM wymagają planu cmux Pro.", + "action": "Przejdź na cmux Pro na stronie {upgradeUrl}, aby tworzyć Cloud VM." } }, "billingRecovery": { diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index d4e575fab9db..b2008ae9d91e 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -15,6 +15,10 @@ "fork": "Não tente novamente. Crie uma nova VM com `cmux vm new`.", "default": "Não tente novamente. Execute `cmux vm ls` ou entre em contato com o suporte se achar que isso está errado." } + }, + "requiresPro": { + "message": "As Cloud VMs exigem um plano cmux Pro.", + "action": "Faça upgrade para o cmux Pro em {upgradeUrl} para criar Cloud VMs." } }, "billingRecovery": { diff --git a/web/messages/ru.json b/web/messages/ru.json index 4f9989ba5fe8..f46227e80d8b 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -15,6 +15,10 @@ "fork": "Не повторяйте попытку. Создайте новую VM с помощью `cmux vm new`.", "default": "Не повторяйте попытку. Выполните `cmux vm ls` или обратитесь в поддержку, если считаете это ошибкой." } + }, + "requiresPro": { + "message": "Для Cloud VM требуется план cmux Pro.", + "action": "Перейдите на cmux Pro на {upgradeUrl}, чтобы создавать Cloud VM." } }, "billingRecovery": { diff --git a/web/messages/th.json b/web/messages/th.json index d940d8c71615..d86465452a15 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -15,6 +15,10 @@ "fork": "อย่าลองใหม่ สร้าง VM ใหม่ด้วย `cmux vm new`", "default": "อย่าลองใหม่ เรียกใช้ `cmux vm ls` หรือติดต่อฝ่ายสนับสนุนหากคิดว่านี่เป็นข้อผิดพลาด" } + }, + "requiresPro": { + "message": "Cloud VM ต้องใช้แผน cmux Pro", + "action": "อัปเกรดเป็น cmux Pro ที่ {upgradeUrl} เพื่อสร้าง Cloud VM" } }, "billingRecovery": { diff --git a/web/messages/tr.json b/web/messages/tr.json index 1eaaa2c15afa..601698428fe7 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -15,6 +15,10 @@ "fork": "Tekrar denemeyin. `cmux vm new` ile yeni bir VM oluşturun.", "default": "Tekrar denemeyin. `cmux vm ls` komutunu çalıştırın veya bunun yanlış olduğunu düşünüyorsanız destek ekibine başvurun." } + }, + "requiresPro": { + "message": "Cloud VM'ler için cmux Pro planı gerekir.", + "action": "Cloud VM oluşturmak için {upgradeUrl} adresinden cmux Pro'ya yükseltin." } }, "billingRecovery": { diff --git a/web/messages/uk.json b/web/messages/uk.json index 5dd2e91deb72..9a449859bbb5 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -15,6 +15,10 @@ "fork": "Не повторюйте спробу. Створіть нову VM за допомогою `cmux vm new`.", "default": "Не повторюйте спробу. Виконайте `cmux vm ls` або зверніться до підтримки, якщо вважаєте це помилкою." } + }, + "requiresPro": { + "message": "Для Cloud VM потрібен план cmux Pro.", + "action": "Перейдіть на cmux Pro на {upgradeUrl}, щоб створювати Cloud VM." } }, "billingRecovery": { diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 09d11e4c9bb5..8a03f84ec2b1 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -15,6 +15,10 @@ "fork": "请勿重试。请使用 `cmux vm new` 创建新的虚拟机。", "default": "请勿重试。使用 `cmux vm ls` 查看虚拟机;如果你认为这是错误,请联系支持团队。" } + }, + "requiresPro": { + "message": "云端虚拟机需要 cmux Pro 套餐。", + "action": "请前往 {upgradeUrl} 升级到 cmux Pro 以创建云端虚拟机。" } }, "billingRecovery": { diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index 93aaf51267af..a740b522162b 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -15,6 +15,10 @@ "fork": "請勿重試。請使用 `cmux vm new` 建立新的虛擬機。", "default": "請勿重試。使用 `cmux vm ls` 查看虛擬機;如果你認為這是錯誤,請聯絡支援團隊。" } + }, + "requiresPro": { + "message": "雲端虛擬機需要 cmux Pro 方案。", + "action": "請前往 {upgradeUrl} 升級至 cmux Pro 以建立雲端虛擬機。" } }, "billingRecovery": { diff --git a/web/scripts/cloud-vm/audit-vercel-env.mjs b/web/scripts/cloud-vm/audit-vercel-env.mjs index 592538a0623f..7819761af098 100755 --- a/web/scripts/cloud-vm/audit-vercel-env.mjs +++ b/web/scripts/cloud-vm/audit-vercel-env.mjs @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { auditCloudVmProviderCoherence } from "./defaultProviderAudit.mjs"; +import { auditFreeProvisioningOverride } from "./freeProvisioningAudit.mjs"; import { forbiddenRuntimeEnvKeys, legacyCloudVmEnvKeys, @@ -38,11 +39,15 @@ try { readFileSync(path.join(webDir, "services", "vms", "images", "manifest.json"), "utf8"), ); const providerCoherence = auditCloudVmProviderCoherence(env, manifest); + // The paid-plan gate is fail-closed by default; a permissive override value + // in a shared environment is an outage-class misconfiguration, not a note. + const freeProvisioning = auditFreeProvisioningOverride(env); const result = { ok: missingRequired.length === 0 && forbiddenPresent.length === 0 && - providerCoherence.problems.length === 0, + providerCoherence.problems.length === 0 && + freeProvisioning.problems.length === 0, target, project: project.projectName, envKeyCount: keys.length, @@ -52,6 +57,7 @@ try { forbiddenPresent, legacyCloudVmPresent, providerCoherence, + freeProvisioning, }; console.log(JSON.stringify(result, null, 2)); diff --git a/web/scripts/cloud-vm/freeProvisioningAudit.mjs b/web/scripts/cloud-vm/freeProvisioningAudit.mjs new file mode 100644 index 000000000000..de6d5de0e628 --- /dev/null +++ b/web/scripts/cloud-vm/freeProvisioningAudit.mjs @@ -0,0 +1,58 @@ +// Mirror of services/vms/entitlements.ts `isVmFreeProvisioningAllowed`. The +// audit script must stay a dependency-free .mjs for CI, so it cannot import +// the runtime module; tests/cloud-vm-env-audit.test.ts pins the two together. + +export const FREE_PROVISIONING_ALLOW_KEY = "CMUX_VM_ALLOW_FREE_PROVISIONING"; +export const FREE_PROVISIONING_LEGACY_KEY = "CMUX_VM_REQUIRE_PRO"; +export const freeProvisioningOverrideEnvKeys = [ + FREE_PROVISIONING_ALLOW_KEY, + FREE_PROVISIONING_LEGACY_KEY, +]; + +const SENSITIVE_PLACEHOLDER = "[SENSITIVE]"; +const TRUTHY = new Set(["1", "true", "yes", "on", "enabled"]); +const FALSY = new Set(["0", "false", "no", "off", "disabled"]); + +function normalized(value) { + return typeof value === "string" ? value.trim().toLowerCase() : ""; +} + +/** Same decision the runtime makes: does this env open free-plan provisioning? */ +export function isFreeProvisioningAllowed(env) { + const allow = env[FREE_PROVISIONING_ALLOW_KEY]; + if (allow !== undefined) return TRUTHY.has(normalized(allow)); + const legacy = env[FREE_PROVISIONING_LEGACY_KEY]; + return legacy !== undefined && FALSY.has(normalized(legacy)); +} + +/** + * Key presence is not enough here: `CMUX_VM_ALLOW_FREE_PROVISIONING=0` is a + * harmless explicit default, while `=1` (or a lone legacy + * `CMUX_VM_REQUIRE_PRO=0`) silently reopens free provisioning in a shared + * environment. Any problem fails the audit. + */ +export function auditFreeProvisioningOverride(env) { + const present = freeProvisioningOverrideEnvKeys.filter((key) => env[key] !== undefined); + const problems = []; + for (const key of present) { + if (env[key] === SENSITIVE_PLACEHOLDER) { + problems.push( + `${key} is stored as a Sensitive env var, so its value cannot be audited and the ` + + "paid-plan gate state is unknown; store it as a plain env var or unset it", + ); + } + } + const allowed = problems.length === 0 && isFreeProvisioningAllowed(env); + if (allowed) { + const cause = env[FREE_PROVISIONING_ALLOW_KEY] !== undefined + ? `${FREE_PROVISIONING_ALLOW_KEY}=${env[FREE_PROVISIONING_ALLOW_KEY]}` + : `legacy ${FREE_PROVISIONING_LEGACY_KEY}=${env[FREE_PROVISIONING_LEGACY_KEY]} with ` + + `${FREE_PROVISIONING_ALLOW_KEY} unset`; + problems.push( + `free Cloud VM provisioning is enabled (${cause}); shared environments must keep the ` + + `paid-plan gate on. Unset ${FREE_PROVISIONING_ALLOW_KEY} and ${FREE_PROVISIONING_LEGACY_KEY}, ` + + `or set ${FREE_PROVISIONING_ALLOW_KEY}=0`, + ); + } + return { present, allowed, problems }; +} diff --git a/web/scripts/cloud-vm/projects.mjs b/web/scripts/cloud-vm/projects.mjs index 12e0a5db2e22..6ba7c4e4a30c 100755 --- a/web/scripts/cloud-vm/projects.mjs +++ b/web/scripts/cloud-vm/projects.mjs @@ -67,9 +67,9 @@ export const recommendedRuntimeEnvKeys = [ // Kill switches are off-only: unset means enabled, so requiring presence // would fail a healthy deployment. Recommended for explicitness (the other // provider flags are set in prod). - // The paid-plan gate is the inverse: unset is the safe value, and setting - // this key is an exceptional, audited decision to allow free provisioning. - "CMUX_VM_ALLOW_FREE_PROVISIONING", + // CMUX_VM_ALLOW_FREE_PROVISIONING / CMUX_VM_REQUIRE_PRO are deliberately + // absent from every presence list: unset is the safe value, and their + // VALUES are audited by freeProvisioningAudit.mjs (a permissive value fails). "CMUX_VM_BLAXEL_ENABLED", "CMUX_DB_SSL_REJECT_UNAUTHORIZED", "OTEL_EXPORTER_OTLP_ENDPOINT", diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index 315df2c27346..0a54aab601c9 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -35,7 +35,7 @@ import { import { recordSpanTiming } from "./timings"; import { authProviderErrorResponse } from "./authErrors"; import { reportVmErrorResponse, VM_ERROR_CODE_HEADER } from "./observability"; -import { vmRequestLocale, vmUnsupportedCopy } from "./vmErrorMessages"; +import { vmRequestLocale, vmRequiresProCopy, vmUnsupportedCopy } from "./vmErrorMessages"; import type { Locale } from "../../i18n/routing"; /** Bearer + refresh token pair the mac app stashes in keychain. */ @@ -285,15 +285,15 @@ type VmProvisioningScopeOptions = { * by construction while management routes can continue to use * `resolveVmRouteAccountScope` without a paywall. */ -export function resolveVmProvisioningAccountScope( +export async function resolveVmProvisioningAccountScope( user: AuthedUser, request: Request, options: VmProvisioningScopeOptions = {}, -): VmRouteAccountScope { +): Promise { const scope = resolveVmAccountScope(user, request, options); if (!scope.ok) return scope; if (isVmProGateBlocked(scope.entitlements)) { - return { ok: false, response: vmRequiresProResponse() }; + return { ok: false, response: await vmRequiresProResponse(vmRequestLocale(request)) }; } return scope; } @@ -346,12 +346,18 @@ export function vmBillingTeamErrorResponse(err: { const VM_UPGRADE_URL = "https://cmux.com/pricing"; -export function vmRequiresProResponse(): Response { +/** + * The paid-plan gate response. Copy comes from the `vmErrors.requiresPro` + * catalog so non-English clients get a translated upgrade instruction; the + * machine-readable `upgradeUrl`/`upgradeRequired` fields stay locale-free. + */ +export async function vmRequiresProResponse(locale: Locale = "en"): Promise { + const copy = await vmRequiresProCopy(locale, { upgradeUrl: VM_UPGRADE_URL }); return vmErrorResponse({ error: "vm_requires_pro", status: 402, - message: "Cloud VMs require a cmux Pro plan.", - action: `Upgrade to cmux Pro at ${VM_UPGRADE_URL} to create Cloud VMs.`, + message: copy.message, + action: copy.action, extra: { upgradeRequired: true, upgradeUrl: VM_UPGRADE_URL }, }); } diff --git a/web/services/vms/vmErrorMessages.ts b/web/services/vms/vmErrorMessages.ts index 657b091d0c7d..aab615ae3169 100644 --- a/web/services/vms/vmErrorMessages.ts +++ b/web/services/vms/vmErrorMessages.ts @@ -56,6 +56,28 @@ export async function vmUnsupportedCopy( }; } +/** Copy returned when a provisioning verb is blocked by the paid-plan gate. */ +export type VmRequiresProCopy = { + readonly message: string; + readonly action: string; +}; + +/** Load and translate the `vm_requires_pro` response copy for the request locale. */ +export async function vmRequiresProCopy( + locale: Locale, + values: { readonly upgradeUrl: string }, +): Promise { + const translator = createTranslator({ + locale, + messages: await loadMessages(locale), + namespace: "vmErrors.requiresPro", + }) as unknown as (key: string, values?: Record) => string; + return { + message: translator("message"), + action: translator("action", { upgradeUrl: values.upgradeUrl }), + }; +} + function localeFromPath(value: string): Locale | null { try { const firstSegment = new URL(value).pathname.split("/").filter(Boolean)[0]; From bc295194f09b8d4863483d79dbfc3f9d3004b683 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 31 Aug 2026 23:29:48 -0700 Subject: [PATCH 5/8] browser: import CmuxBrowser in BrowserPopupWindowController BrowserAppLinkOpenRequest (CmuxBrowser package) has been used here since #10634, but this file never imported the module, so the tagged Debug build fails with "cannot find 'BrowserAppLinkOpenRequest' in scope". The sibling users (BrowserPanel, BrowserNavigationDelegate) already import it. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01886xVcepPfsLRFCrFXLh1M --- Sources/Panels/BrowserPopupWindowController.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Sources/Panels/BrowserPopupWindowController.swift b/Sources/Panels/BrowserPopupWindowController.swift index 4810a7cd6a2a..370cddea5a53 100644 --- a/Sources/Panels/BrowserPopupWindowController.swift +++ b/Sources/Panels/BrowserPopupWindowController.swift @@ -1,5 +1,6 @@ import AppKit import Bonsplit +import CmuxBrowser import CmuxFoundation import CmuxSettings import ObjectiveC From 2a26e70abb620e9ca8bb3563b68427ce6bc3c4cc Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 31 Aug 2026 23:38:07 -0700 Subject: [PATCH 6/8] markdown: make onViewAttachedToWindow a memberwise-init parameter A `let` with a default value is excluded from Swift's synthesized memberwise initializer, so MarkdownPanelView's `onViewAttachedToWindow:` argument (added in #11059) does not compile. Mirrors #11346 so this branch builds before that fix lands on main; the hunks are identical and merge cleanly. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01886xVcepPfsLRFCrFXLh1M --- Sources/Panels/MarkdownWebRenderer.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Panels/MarkdownWebRenderer.swift b/Sources/Panels/MarkdownWebRenderer.swift index 4d9d3ba9a33f..4a5ae9b6e41a 100644 --- a/Sources/Panels/MarkdownWebRenderer.swift +++ b/Sources/Panels/MarkdownWebRenderer.swift @@ -26,7 +26,7 @@ struct MarkdownWebRenderer: NSViewRepresentable { /// Called after the renderer view is attached to a window. A panel can /// request focus before SwiftUI mounts its WebKit view, so the panel uses /// this lifecycle signal to complete that request without polling. - let onViewAttachedToWindow: () -> Void = {} + var onViewAttachedToWindow: () -> Void = {} func makeCoordinator() -> Coordinator { session.coordinator(panelId: panelId, workspaceId: workspaceId, filePath: filePath) From 105469252d113fa418dec5f1ba87394e0cb56aab Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Mon, 31 Aug 2026 23:58:04 -0700 Subject: [PATCH 7/8] vm: localize the vm_requires_pro display title ui.title fell back to the English status-based default ("Cloud VM limit reached"), so non-English clients got a mixed-language upgrade prompt. vmErrors.requiresPro now carries a title in every catalog and vmRequiresProResponse passes it as displayTitle; the locale tests assert ui.title per catalog and at the route level. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01886xVcepPfsLRFCrFXLh1M --- web/messages/ar.json | 1 + web/messages/bs.json | 1 + web/messages/da.json | 1 + web/messages/de.json | 1 + web/messages/en.json | 1 + web/messages/es.json | 1 + web/messages/fr.json | 1 + web/messages/it.json | 1 + web/messages/ja.json | 1 + web/messages/km.json | 1 + web/messages/ko.json | 1 + web/messages/no.json | 1 + web/messages/pl.json | 1 + web/messages/pt-BR.json | 1 + web/messages/ru.json | 1 + web/messages/th.json | 1 + web/messages/tr.json | 1 + web/messages/uk.json | 1 + web/messages/zh-CN.json | 1 + web/messages/zh-TW.json | 1 + web/services/vms/routeHelpers.ts | 1 + web/services/vms/vmErrorMessages.ts | 2 ++ web/tests/vm-pro-gate.test.ts | 17 ++++++++++++++--- web/tests/vm-route-auth.test.ts | 5 ++++- 24 files changed, 41 insertions(+), 4 deletions(-) diff --git a/web/messages/ar.json b/web/messages/ar.json index 7d7772412783..850a0e418d46 100644 --- a/web/messages/ar.json +++ b/web/messages/ar.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "يتطلب cmux Pro", "message": "تتطلب أجهزة Cloud VM خطة cmux Pro.", "action": "قم بالترقية إلى cmux Pro عبر {upgradeUrl} لإنشاء أجهزة Cloud VM." } diff --git a/web/messages/bs.json b/web/messages/bs.json index 4d32ce25387e..eaa346c4a3d4 100644 --- a/web/messages/bs.json +++ b/web/messages/bs.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "Potreban je cmux Pro", "message": "Cloud VM-ovi zahtijevaju cmux Pro plan.", "action": "Nadogradite na cmux Pro na {upgradeUrl} da biste kreirali Cloud VM-ove." } diff --git a/web/messages/da.json b/web/messages/da.json index 3ffcecc16daf..1921a0fdc325 100644 --- a/web/messages/da.json +++ b/web/messages/da.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "cmux Pro påkrævet", "message": "Cloud VM'er kræver en cmux Pro-plan.", "action": "Opgrader til cmux Pro på {upgradeUrl} for at oprette Cloud VM'er." } diff --git a/web/messages/de.json b/web/messages/de.json index 12d7e0b3492f..d46d556362f1 100644 --- a/web/messages/de.json +++ b/web/messages/de.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "cmux Pro erforderlich", "message": "Cloud-VMs erfordern einen cmux Pro-Plan.", "action": "Führen Sie unter {upgradeUrl} ein Upgrade auf cmux Pro durch, um Cloud-VMs zu erstellen." } diff --git a/web/messages/en.json b/web/messages/en.json index 11adcee9f77f..4799e3fc30aa 100644 --- a/web/messages/en.json +++ b/web/messages/en.json @@ -26,6 +26,7 @@ } }, "requiresPro": { + "title": "cmux Pro required", "message": "Cloud VMs require a cmux Pro plan.", "action": "Upgrade to cmux Pro at {upgradeUrl} to create Cloud VMs." } diff --git a/web/messages/es.json b/web/messages/es.json index c582501eb8ee..b8bcb4997b3d 100644 --- a/web/messages/es.json +++ b/web/messages/es.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "Se requiere cmux Pro", "message": "Las Cloud VM requieren un plan cmux Pro.", "action": "Actualiza a cmux Pro en {upgradeUrl} para crear Cloud VM." } diff --git a/web/messages/fr.json b/web/messages/fr.json index 8b180751d790..7c7f58ccf9c5 100644 --- a/web/messages/fr.json +++ b/web/messages/fr.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "cmux Pro requis", "message": "Les Cloud VM nécessitent un abonnement cmux Pro.", "action": "Passez à cmux Pro sur {upgradeUrl} pour créer des Cloud VM." } diff --git a/web/messages/it.json b/web/messages/it.json index 98b593e0edca..5897a4bb2fc2 100644 --- a/web/messages/it.json +++ b/web/messages/it.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "Richiesto cmux Pro", "message": "Le Cloud VM richiedono un piano cmux Pro.", "action": "Passa a cmux Pro su {upgradeUrl} per creare Cloud VM." } diff --git a/web/messages/ja.json b/web/messages/ja.json index 2d27e389a91d..a40235733780 100644 --- a/web/messages/ja.json +++ b/web/messages/ja.json @@ -26,6 +26,7 @@ } }, "requiresPro": { + "title": "cmux Pro が必要です", "message": "Cloud VM を利用するには cmux Pro プランが必要です。", "action": "{upgradeUrl} で cmux Pro にアップグレードすると Cloud VM を作成できます。" } diff --git a/web/messages/km.json b/web/messages/km.json index 712d16574b77..f3a7c1298064 100644 --- a/web/messages/km.json +++ b/web/messages/km.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "ត្រូវការ cmux Pro", "message": "Cloud VM តម្រូវឱ្យមានគម្រោង cmux Pro។", "action": "ដំឡើងកំណែទៅ cmux Pro នៅ {upgradeUrl} ដើម្បីបង្កើត Cloud VM។" } diff --git a/web/messages/ko.json b/web/messages/ko.json index 47c877b844c1..660d7dcf310c 100644 --- a/web/messages/ko.json +++ b/web/messages/ko.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "cmux Pro 필요", "message": "Cloud VM을 사용하려면 cmux Pro 플랜이 필요합니다.", "action": "{upgradeUrl}에서 cmux Pro로 업그레이드하면 Cloud VM을 만들 수 있습니다." } diff --git a/web/messages/no.json b/web/messages/no.json index ef7bec25166e..67e5dee31e77 100644 --- a/web/messages/no.json +++ b/web/messages/no.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "cmux Pro kreves", "message": "Cloud VM-er krever en cmux Pro-plan.", "action": "Oppgrader til cmux Pro på {upgradeUrl} for å opprette Cloud VM-er." } diff --git a/web/messages/pl.json b/web/messages/pl.json index a2ffa2ef51d5..f2fbd0b84bb9 100644 --- a/web/messages/pl.json +++ b/web/messages/pl.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "Wymagany cmux Pro", "message": "Cloud VM wymagają planu cmux Pro.", "action": "Przejdź na cmux Pro na stronie {upgradeUrl}, aby tworzyć Cloud VM." } diff --git a/web/messages/pt-BR.json b/web/messages/pt-BR.json index b2008ae9d91e..30144b15c47f 100644 --- a/web/messages/pt-BR.json +++ b/web/messages/pt-BR.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "cmux Pro necessário", "message": "As Cloud VMs exigem um plano cmux Pro.", "action": "Faça upgrade para o cmux Pro em {upgradeUrl} para criar Cloud VMs." } diff --git a/web/messages/ru.json b/web/messages/ru.json index f46227e80d8b..aeeed5ced8ab 100644 --- a/web/messages/ru.json +++ b/web/messages/ru.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "Требуется cmux Pro", "message": "Для Cloud VM требуется план cmux Pro.", "action": "Перейдите на cmux Pro на {upgradeUrl}, чтобы создавать Cloud VM." } diff --git a/web/messages/th.json b/web/messages/th.json index d86465452a15..24845ec055e9 100644 --- a/web/messages/th.json +++ b/web/messages/th.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "ต้องใช้ cmux Pro", "message": "Cloud VM ต้องใช้แผน cmux Pro", "action": "อัปเกรดเป็น cmux Pro ที่ {upgradeUrl} เพื่อสร้าง Cloud VM" } diff --git a/web/messages/tr.json b/web/messages/tr.json index 601698428fe7..a5a91c43a722 100644 --- a/web/messages/tr.json +++ b/web/messages/tr.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "cmux Pro gerekli", "message": "Cloud VM'ler için cmux Pro planı gerekir.", "action": "Cloud VM oluşturmak için {upgradeUrl} adresinden cmux Pro'ya yükseltin." } diff --git a/web/messages/uk.json b/web/messages/uk.json index 9a449859bbb5..e6b87f096322 100644 --- a/web/messages/uk.json +++ b/web/messages/uk.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "Потрібен cmux Pro", "message": "Для Cloud VM потрібен план cmux Pro.", "action": "Перейдіть на cmux Pro на {upgradeUrl}, щоб створювати Cloud VM." } diff --git a/web/messages/zh-CN.json b/web/messages/zh-CN.json index 8a03f84ec2b1..ce4b642923c4 100644 --- a/web/messages/zh-CN.json +++ b/web/messages/zh-CN.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "需要 cmux Pro", "message": "云端虚拟机需要 cmux Pro 套餐。", "action": "请前往 {upgradeUrl} 升级到 cmux Pro 以创建云端虚拟机。" } diff --git a/web/messages/zh-TW.json b/web/messages/zh-TW.json index a740b522162b..6f6392037b1c 100644 --- a/web/messages/zh-TW.json +++ b/web/messages/zh-TW.json @@ -17,6 +17,7 @@ } }, "requiresPro": { + "title": "需要 cmux Pro", "message": "雲端虛擬機需要 cmux Pro 方案。", "action": "請前往 {upgradeUrl} 升級至 cmux Pro 以建立雲端虛擬機。" } diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index 0a54aab601c9..2a33cb374acc 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -358,6 +358,7 @@ export async function vmRequiresProResponse(locale: Locale = "en"): Promise) => string; return { + title: translator("title"), message: translator("message"), action: translator("action", { upgradeUrl: values.upgradeUrl }), }; diff --git a/web/tests/vm-pro-gate.test.ts b/web/tests/vm-pro-gate.test.ts index 70aa0313a9e4..dea6002792f7 100644 --- a/web/tests/vm-pro-gate.test.ts +++ b/web/tests/vm-pro-gate.test.ts @@ -76,6 +76,7 @@ describe("vm_requires_pro response copy", () => { action: "Upgrade to cmux Pro at https://cmux.com/pricing to create Cloud VMs.", upgradeRequired: true, upgradeUrl: "https://cmux.com/pricing", + ui: { title: "cmux Pro required" }, }); }); @@ -85,26 +86,36 @@ describe("vm_requires_pro response copy", () => { expect(String(payload.action)).toContain("https://cmux.com/pricing"); expect(String(payload.action)).toContain("cmux Pro にアップグレード"); // Clients key off these, never the prose. - expect(payload).toMatchObject({ error: "vm_requires_pro", upgradeRequired: true }); + expect(payload).toMatchObject({ + error: "vm_requires_pro", + upgradeRequired: true, + ui: { title: "cmux Pro が必要です" }, + }); }); test("ships translated copy with the upgrade URL placeholder in every locale catalog", async () => { for (const locale of locales) { const messages = (await import(`../messages/${locale}.json`)).default as { - vmErrors: { requiresPro?: { message?: string; action?: string } }; + vmErrors: { requiresPro?: { title?: string; message?: string; action?: string } }; }; const copy = messages.vmErrors.requiresPro; - const payload = await (await vmRequiresProResponse(locale)).json() as { action: string }; + const payload = await (await vmRequiresProResponse(locale)).json() as { + action: string; + ui: { title: string }; + }; // Keyed by locale so a failure names the catalog that is missing or broken. expect({ locale, hasMessage: Boolean(copy?.message), + // ui.title must come from the catalog, never the English status fallback. + uiTitleIsLocalized: Boolean(copy?.title) && payload.ui.title === copy?.title, actionHasPlaceholder: copy?.action?.includes("{upgradeUrl}") ?? false, renderedHasUrl: payload.action.includes("https://cmux.com/pricing"), renderedHasRawPlaceholder: payload.action.includes("{upgradeUrl}"), }).toEqual({ locale, hasMessage: true, + uiTitleIsLocalized: true, actionHasPlaceholder: true, renderedHasUrl: true, renderedHasRawPlaceholder: false, diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 893ec71511fa..973b89400595 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -662,9 +662,12 @@ describe("VM REST auth", () => { ); expect(response.status).toBe(402); - const payload = await response.json() as { error: string; message: string; action: string; upgradeUrl: string }; + const payload = await response.json() as { + error: string; message: string; action: string; upgradeUrl: string; ui: { title: string }; + }; expect(payload.error).toBe("vm_requires_pro"); expect(payload.message).toBe("Cloud VM を利用するには cmux Pro プランが必要です。"); + expect(payload.ui.title).toBe("cmux Pro が必要です"); expect(payload.action).toContain("https://cmux.com/pricing"); expect(payload.upgradeUrl).toBe("https://cmux.com/pricing"); expect(createVm).not.toHaveBeenCalled(); From 990de371fbdbed2346b63e2c3c1a98302834bbf0 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 1 Sep 2026 00:01:53 -0700 Subject: [PATCH 8/8] tests: import Bonsplit in SidebarFileDropFindRoutingTests The test (added in #11059) uses BonsplitController without importing the module, so the cmux-unit scheme does not compile on main. Mirrors the identical hunk in #11346. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01886xVcepPfsLRFCrFXLh1M --- cmuxTests/SidebarFileDropFindRoutingTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/cmuxTests/SidebarFileDropFindRoutingTests.swift b/cmuxTests/SidebarFileDropFindRoutingTests.swift index 1bade3cc5cd6..f61c74c0af4a 100644 --- a/cmuxTests/SidebarFileDropFindRoutingTests.swift +++ b/cmuxTests/SidebarFileDropFindRoutingTests.swift @@ -1,3 +1,4 @@ +import Bonsplit import AppKit import Testing import WebKit