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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions Resources/Localizable.xcstrings
Original file line number Diff line number Diff line change
Expand Up @@ -126716,6 +126716,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": {
Expand Down
17 changes: 15 additions & 2 deletions Sources/Cloud/MachinesPanelViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Comment thread
austinywang marked this conversation as resolved.
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 }

Expand Down Expand Up @@ -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,
Expand Down
5 changes: 5 additions & 0 deletions Sources/Cloud/VMClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 3 additions & 1 deletion Sources/HostSettingsActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
32 changes: 32 additions & 0 deletions cmuxTests/MachinesPanelModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -966,3 +966,35 @@ struct MachinesPanelListProblemTests {
XCTAssertEqual(CloudTreeTerminalRowContent.multiplierBadge(5), 5)
}
}

@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"))
}
}
1 change: 1 addition & 0 deletions cmuxTests/SidebarFileDropFindRoutingTests.swift
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Bonsplit
import AppKit
import Testing
import WebKit
Expand Down
5 changes: 5 additions & 0 deletions docs/cloud-vm-backend-rollout-todo.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +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; 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`
Expand Down
14 changes: 14 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# 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=
Expand Down Expand Up @@ -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
Expand Down
23 changes: 4 additions & 19 deletions web/app/api/vm/[id]/fork/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 = await 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, {
Expand Down
24 changes: 4 additions & 20 deletions web/app/api/vm/base/routeShared.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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";
Expand All @@ -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 = await 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.
Expand Down
54 changes: 20 additions & 34 deletions web/app/api/vm/restore/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -71,10 +65,26 @@ export async function POST(request: Request): Promise<Response> {
}
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 = await 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) {
Expand All @@ -91,30 +101,6 @@ export async function POST(request: Request): Promise<Response> {
}
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,
Expand Down
Loading
Loading