From e7e90546deeac4410c4946007d6f157cab27c63a Mon Sep 17 00:00:00 2001 From: cmux reload-cloud Date: Tue, 25 Aug 2026 15:11:15 -0700 Subject: [PATCH 01/53] cloud: one bulky free machine with a 5-day window, Pro gets five MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plan shape: the free plan now includes one full-size machine (24 GB default and cap — the free machine demos the product; the paywall is the window and the count, not the machine's usefulness) and Pro includes five machines (24 GB default, 32 GB cap). 24576 joins the memory picker options. All numbers stay env-overridable per plan. Free access window: a free-plan machine older than 5 days (CMUX_VM_FREE_ACCESS_WINDOW_DAYS, 0 disables) is preserved but unreachable — attach, ssh, exec, ports, and sessions fail with a 402 vm_access_requires_pro upgrade prompt, while list/status/rename/delete keep working so the machine stays visible and disposable. The gate keys on the caller's CURRENT plan, so upgrading unlocks existing machines immediately. Enforced in one place (requireAccessibleUserVm) that every access workflow shares; the five REST routes thread the caller's plan and map the typed error. Co-Authored-By: Claude Fable 5 --- web/app/api/vm/[id]/attach-endpoint/route.ts | 7 ++- web/app/api/vm/[id]/exec/route.ts | 7 ++- web/app/api/vm/[id]/open-port/route.ts | 7 ++- web/app/api/vm/[id]/sessions/route.ts | 11 +++- web/app/api/vm/[id]/ssh-endpoint/route.ts | 7 ++- web/services/vms/entitlements.ts | 59 ++++++++++++++++--- web/services/vms/errors.ts | 12 ++++ web/services/vms/routeHelpers.ts | 20 +++++++ web/services/vms/workflows.ts | 45 +++++++++++++-- web/tests/vm-billing-limit-paywall.test.ts | 61 ++++++++++++++++---- web/tests/vm-route-auth.test.ts | 16 ++--- 11 files changed, 216 insertions(+), 36 deletions(-) diff --git a/web/app/api/vm/[id]/attach-endpoint/route.ts b/web/app/api/vm/[id]/attach-endpoint/route.ts index b7b16481d2a5..196ef178c359 100644 --- a/web/app/api/vm/[id]/attach-endpoint/route.ts +++ b/web/app/api/vm/[id]/attach-endpoint/route.ts @@ -1,11 +1,12 @@ import { jsonResponse, notFoundVm, + vmFreeAccessExpiredResponse, resolveVmRouteAccountScope, withAuthedVmApiRoute, } from "../../../../../services/vms/routeHelpers"; import { setSpanAttributes } from "../../../../../services/telemetry"; -import { isVmNotFoundError } from "../../../../../services/vms/errors"; +import { isVmFreeAccessExpiredError, isVmNotFoundError } from "../../../../../services/vms/errors"; import { openAttachEndpoint, runVmWorkflow } from "../../../../../services/vms/workflows"; @@ -43,6 +44,7 @@ export async function POST( const endpoint = await runVmWorkflow(openAttachEndpoint({ userId: user.id, billingTeamId: account.entitlements.billingTeamId, + callerPlanId: account.entitlements.planId, teamIds: user.teamIds, providerVmId: id, sessionTitle, @@ -51,6 +53,9 @@ export async function POST( setSpanAttributes(span, { "cmux.vm.attach.transport": endpoint.transport }); return jsonResponse(endpoint); } catch (err) { + if (isVmFreeAccessExpiredError(err)) { + return vmFreeAccessExpiredResponse({ vmId: id, windowDays: err.windowDays }); + } if (isVmNotFoundError(err)) return notFoundVm(id); throw err; } diff --git a/web/app/api/vm/[id]/exec/route.ts b/web/app/api/vm/[id]/exec/route.ts index 56c2ac774889..8768903dbedc 100644 --- a/web/app/api/vm/[id]/exec/route.ts +++ b/web/app/api/vm/[id]/exec/route.ts @@ -1,12 +1,13 @@ import { jsonResponse, notFoundVm, + vmFreeAccessExpiredResponse, resolveVmRouteAccountScope, vmErrorResponse, withAuthedVmApiRoute, } from "../../../../../services/vms/routeHelpers"; import { setSpanAttributes } from "../../../../../services/telemetry"; -import { isVmNotFoundError } from "../../../../../services/vms/errors"; +import { isVmFreeAccessExpiredError, isVmNotFoundError } from "../../../../../services/vms/errors"; import { execVm, runVmWorkflow } from "../../../../../services/vms/workflows"; @@ -71,6 +72,7 @@ export async function POST( const result = await runVmWorkflow(execVm({ userId: user.id, billingTeamId: account.entitlements.billingTeamId, + callerPlanId: account.entitlements.planId, teamIds: user.teamIds, providerVmId: id, command, @@ -79,6 +81,9 @@ export async function POST( setSpanAttributes(span, { "cmux.exec.exit_code": result.exitCode }); return jsonResponse(result); } catch (err) { + if (isVmFreeAccessExpiredError(err)) { + return vmFreeAccessExpiredResponse({ vmId: id, windowDays: err.windowDays }); + } if (isVmNotFoundError(err)) return notFoundVm(id); throw err; } diff --git a/web/app/api/vm/[id]/open-port/route.ts b/web/app/api/vm/[id]/open-port/route.ts index d383a6799984..c4b18b584ecf 100644 --- a/web/app/api/vm/[id]/open-port/route.ts +++ b/web/app/api/vm/[id]/open-port/route.ts @@ -1,12 +1,13 @@ import { jsonResponse, notFoundVm, + vmFreeAccessExpiredResponse, resolveVmRouteAccountScope, vmErrorResponse, withAuthedVmApiRoute, } from "../../../../../services/vms/routeHelpers"; import { setSpanAttributes } from "../../../../../services/telemetry"; -import { isVmNotFoundError } from "../../../../../services/vms/errors"; +import { isVmFreeAccessExpiredError, isVmNotFoundError } from "../../../../../services/vms/errors"; import { openVmPort, runVmWorkflow } from "../../../../../services/vms/workflows"; @@ -59,12 +60,16 @@ export async function POST( const endpoint = await runVmWorkflow(openVmPort({ userId: user.id, billingTeamId: account.entitlements.billingTeamId, + callerPlanId: account.entitlements.planId, teamIds: user.teamIds, providerVmId: id, port, })); return jsonResponse(endpoint); } catch (err) { + if (isVmFreeAccessExpiredError(err)) { + return vmFreeAccessExpiredResponse({ vmId: id, windowDays: err.windowDays }); + } if (isVmNotFoundError(err)) return notFoundVm(id); throw err; } diff --git a/web/app/api/vm/[id]/sessions/route.ts b/web/app/api/vm/[id]/sessions/route.ts index 0c367bf46378..af8ffda662b2 100644 --- a/web/app/api/vm/[id]/sessions/route.ts +++ b/web/app/api/vm/[id]/sessions/route.ts @@ -1,11 +1,12 @@ import { jsonResponse, notFoundVm, + vmFreeAccessExpiredResponse, resolveVmRouteAccountScope, withAuthedVmApiRoute, } from "../../../../../services/vms/routeHelpers"; import { setSpanAttributes } from "../../../../../services/telemetry"; -import { isVmNotFoundError } from "../../../../../services/vms/errors"; +import { isVmFreeAccessExpiredError, isVmNotFoundError } from "../../../../../services/vms/errors"; import { listVmSessions, openVmSession, @@ -32,11 +33,15 @@ export async function GET( const sessions = await runVmWorkflow(listVmSessions({ userId: user.id, billingTeamId: account.entitlements.billingTeamId, + callerPlanId: account.entitlements.planId, teamIds: user.teamIds, providerVmId: id, })); return jsonResponse({ sessions: sessions.map(sessionPayload) }); } catch (err) { + if (isVmFreeAccessExpiredError(err)) { + return vmFreeAccessExpiredResponse({ vmId: id, windowDays: err.windowDays }); + } if (isVmNotFoundError(err)) return notFoundVm(id); throw err; } @@ -76,6 +81,7 @@ export async function POST( const result = await runVmWorkflow(openVmSession({ userId: user.id, billingTeamId: account.entitlements.billingTeamId, + callerPlanId: account.entitlements.planId, teamIds: user.teamIds, providerVmId: id, sessionId, @@ -87,6 +93,9 @@ export async function POST( session: result.session ? sessionPayload(result.session) : null, }); } catch (err) { + if (isVmFreeAccessExpiredError(err)) { + return vmFreeAccessExpiredResponse({ vmId: id, windowDays: err.windowDays }); + } if (isVmNotFoundError(err)) return notFoundVm(id); throw err; } diff --git a/web/app/api/vm/[id]/ssh-endpoint/route.ts b/web/app/api/vm/[id]/ssh-endpoint/route.ts index ed8f885f0467..161b967d79bc 100644 --- a/web/app/api/vm/[id]/ssh-endpoint/route.ts +++ b/web/app/api/vm/[id]/ssh-endpoint/route.ts @@ -1,11 +1,12 @@ import { jsonResponse, notFoundVm, + vmFreeAccessExpiredResponse, resolveVmRouteAccountScope, withAuthedVmApiRoute, } from "../../../../../services/vms/routeHelpers"; import { setSpanAttributes } from "../../../../../services/telemetry"; -import { isVmNotFoundError } from "../../../../../services/vms/errors"; +import { isVmFreeAccessExpiredError, isVmNotFoundError } from "../../../../../services/vms/errors"; import { openSshEndpoint, runVmWorkflow } from "../../../../../services/vms/workflows"; @@ -39,12 +40,16 @@ export async function POST( const endpoint = await runVmWorkflow(openSshEndpoint({ userId: user.id, billingTeamId: account.entitlements.billingTeamId, + callerPlanId: account.entitlements.planId, teamIds: user.teamIds, providerVmId: id, })); setSpanAttributes(span, { "cmux.ssh.credential_kind": endpoint.credential.kind }); return jsonResponse(endpoint); } catch (err) { + if (isVmFreeAccessExpiredError(err)) { + return vmFreeAccessExpiredResponse({ vmId: id, windowDays: err.windowDays }); + } if (isVmNotFoundError(err)) return notFoundVm(id); throw err; } diff --git a/web/services/vms/entitlements.ts b/web/services/vms/entitlements.ts index 7c5ba5a784e6..d9c46af55b0b 100644 --- a/web/services/vms/entitlements.ts +++ b/web/services/vms/entitlements.ts @@ -113,7 +113,7 @@ function resolveBillingContext( * Machine sizes a person can pick, as memory in MB. Blaxel scales vCPUs with * memory (a 4 GB machine reports 2 cpus), so memory is the whole size story. */ -export const VM_MEMORY_OPTIONS_MB: readonly number[] = [2048, 4096, 8192, 16384, 32768]; +export const VM_MEMORY_OPTIONS_MB: readonly number[] = [2048, 4096, 8192, 16384, 24576, 32768]; /** Largest machine a plan may create. Env-overridable per plan. */ export function maxMemoryMbForPlan( @@ -125,7 +125,10 @@ export function maxMemoryMbForPlan( const specific = env[`CMUX_VM_PLAN_${planKey}_MAX_MEMORY_MB`]; if (specific?.trim()) return positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_MAX_MEMORY_MB`); if (normalized === "free") { - return positiveInteger(env.CMUX_VM_FREE_MAX_MEMORY_MB ?? "4096", "CMUX_VM_FREE_MAX_MEMORY_MB"); + // The free machine is the product demo: one full-size computer, not a + // cut-down teaser. The paywall is the 5-day access window and the + // machine count, never the machine's usefulness. + return positiveInteger(env.CMUX_VM_FREE_MAX_MEMORY_MB ?? "24576", "CMUX_VM_FREE_MAX_MEMORY_MB"); } return positiveInteger(env.CMUX_VM_PAID_MAX_MEMORY_MB ?? "32768", "CMUX_VM_PAID_MAX_MEMORY_MB"); } @@ -141,8 +144,8 @@ export function defaultMemoryMbForPlan( const raw = specific?.trim() ? positiveInteger(specific, `CMUX_VM_PLAN_${planKey}_DEFAULT_MEMORY_MB`) : normalized === "free" - ? positiveInteger(env.CMUX_VM_FREE_DEFAULT_MEMORY_MB ?? "4096", "CMUX_VM_FREE_DEFAULT_MEMORY_MB") - : positiveInteger(env.CMUX_VM_PAID_DEFAULT_MEMORY_MB ?? "8192", "CMUX_VM_PAID_DEFAULT_MEMORY_MB"); + ? positiveInteger(env.CMUX_VM_FREE_DEFAULT_MEMORY_MB ?? "24576", "CMUX_VM_FREE_DEFAULT_MEMORY_MB") + : positiveInteger(env.CMUX_VM_PAID_DEFAULT_MEMORY_MB ?? "24576", "CMUX_VM_PAID_DEFAULT_MEMORY_MB"); return Math.min(raw, maxMemoryMbForPlan(planId, env)); } @@ -153,6 +156,45 @@ export function maxActiveVmsForPlan( return activeVmLimitForPlan(normalizedPlanId(planId ?? ""), env); } +/** + * How long a free-plan machine stays reachable after it is created, in days. + * After the window the machine (and its data) is preserved, but every access + * verb (attach, ssh, exec, ports, sessions) requires a paid plan; list/status/ + * delete keep working so the machine is visible and disposable. 0 disables + * the window entirely (env kill switch). + */ +export function vmFreeAccessWindowDays( + env: Record = process.env, +): number { + const raw = env.CMUX_VM_FREE_ACCESS_WINDOW_DAYS; + if (raw === undefined || !raw.trim()) return 5; + const parsed = Number.parseInt(raw.trim(), 10); + if (!Number.isSafeInteger(parsed) || parsed < 0) { + throw new Error(`CMUX_VM_FREE_ACCESS_WINDOW_DAYS must be a non-negative integer, got: ${raw}`); + } + return parsed; +} + +/** + * Whether the caller's CURRENT plan has outlived the free access window for a + * machine created at `createdAt`. Deliberately keyed on the caller's plan, not + * the plan recorded at create time: upgrading to Pro unlocks every machine the + * user already has. + */ +export function isVmFreeAccessExpired( + callerPlanId: string | null | undefined, + createdAt: Date | number | null | undefined, + env: Record = process.env, + nowMs: number = Date.now(), +): boolean { + if (isPaidVmPlan(normalizedPlanId(callerPlanId ?? ""))) return false; + const windowDays = vmFreeAccessWindowDays(env); + if (windowDays <= 0) return false; + const createdMs = createdAt instanceof Date ? createdAt.getTime() : createdAt; + if (typeof createdMs !== "number" || !Number.isFinite(createdMs)) return false; + return nowMs - createdMs > windowDays * 24 * 60 * 60 * 1000; +} + /** A paid Cloud VM plan is Pro or Team; everything else (free) is not. */ export function isPaidVmPlan(planId: string): boolean { const normalized = normalizedPlanId(planId); @@ -203,12 +245,13 @@ function activeVmLimitForPlan(planId: string, env: Record {} +/** A free-plan machine whose access window has lapsed; upgrading unlocks it. */ +export class VmFreeAccessExpiredError extends Data.TaggedError("VmFreeAccessExpiredError")<{ + readonly vmId: string; + readonly windowDays: number; +}> {} + export class VmCreateInProgressError extends Data.TaggedError("VmCreateInProgressError")<{ readonly idempotencyKey: string; }> {} @@ -75,6 +81,7 @@ export type VmWorkflowError = | VmProviderOperationError | VmNotFoundError | VmSnapshotNotFoundError + | VmFreeAccessExpiredError | VmCreateInProgressError | VmCreateFailedError | VmCreateDisabledError @@ -93,6 +100,10 @@ export function isVmSnapshotNotFoundError(err: unknown): err is VmSnapshotNotFou return (err as { _tag?: string } | null)?._tag === "VmSnapshotNotFoundError"; } +export function isVmFreeAccessExpiredError(err: unknown): err is VmFreeAccessExpiredError { + return (err as { _tag?: string } | null)?._tag === "VmFreeAccessExpiredError"; +} + export function isVmCreateInProgressError(err: unknown): err is VmCreateInProgressError { return (err as { _tag?: string } | null)?._tag === "VmCreateInProgressError"; } @@ -145,6 +156,7 @@ const vmWorkflowErrorTags = new Set([ "VmDatabaseError", "VmProviderOperationError", "VmNotFoundError", + "VmFreeAccessExpiredError", "VmCreateInProgressError", "VmCreateFailedError", "VmCreateDisabledError", diff --git a/web/services/vms/routeHelpers.ts b/web/services/vms/routeHelpers.ts index 653715480069..bb9d9911c9c0 100644 --- a/web/services/vms/routeHelpers.ts +++ b/web/services/vms/routeHelpers.ts @@ -191,6 +191,26 @@ export function vmErrorResponse(input: VmErrorResponseInput): Response { }); } +/** + * The paywall response for a free-plan machine whose access window lapsed: + * the machine and its data are preserved, reconnecting requires Pro. 402 with + * `upgradeRequired`/`upgradeUrl` so clients render an upgrade prompt, mirroring + * the free-plan variant of `vmActiveLimitExceededResponse`. + */ +export function vmFreeAccessExpiredResponse(input: { + readonly vmId: string; + readonly windowDays: number; +}): Response { + return vmErrorResponse({ + error: "vm_access_requires_pro", + status: 402, + message: `The free plan includes ${input.windowDays} days of access to a machine. ${input.vmId} is past that window — the machine and everything on it are preserved, and upgrading to Pro reconnects it.`, + action: `Upgrade to Pro at ${VM_UPGRADE_URL} to reconnect ${input.vmId}, or delete it with \`cmux vm rm ${input.vmId}\`.`, + extra: { upgradeRequired: true, upgradeUrl: VM_UPGRADE_URL }, + details: { vmId: input.vmId, windowDays: input.windowDays }, + }); +} + export function notFoundVm(vmId: string): Response { return vmErrorResponse({ error: "vm_not_found", diff --git a/web/services/vms/workflows.ts b/web/services/vms/workflows.ts index 61840dafbd06..476415b0e33a 100644 --- a/web/services/vms/workflows.ts +++ b/web/services/vms/workflows.ts @@ -22,6 +22,7 @@ import { VmAccountDeletionIdentityRevocationError, VmCreateFailedError, VmCreateInProgressError, + VmFreeAccessExpiredError, VmNotFoundError, VmProviderOperationError, VmSnapshotNotFoundError, @@ -31,7 +32,7 @@ import { type VmDatabaseError, type VmWorkflowError, } from "./errors"; -import { maxActiveVmsForPlan } from "./entitlements"; +import { isVmFreeAccessExpired, maxActiveVmsForPlan, vmFreeAccessWindowDays } from "./entitlements"; import { isProviderIdentityNotFoundError, isProviderNotFoundError } from "./providerErrors"; import { VmProviderGateway, VmProviderGatewayLive, type VmProviderGatewayShape } from "./providerGateway"; import { @@ -85,6 +86,8 @@ type ExistingVmAccessInput = { readonly teamIds?: readonly string[]; readonly providerVmId: string; readonly provider?: ProviderId; + /** Caller's CURRENT billing plan; access verbs use it for the free window. */ + readonly callerPlanId?: string | null; }; export type VmProviderStatusReconcileResult = { @@ -1460,11 +1463,13 @@ export function execVm(input: { readonly providerVmId: string; readonly command: string; readonly timeoutMs: number; + /** Caller's CURRENT billing plan; used for the free access window. */ + readonly callerPlanId?: string | null; }) { return Effect.gen(function* () { const repo = yield* VmRepository; const providers = yield* VmProviderGateway; - const vm = yield* requireUserVm(input); + const vm = yield* requireAccessibleUserVm(input); yield* preflightResumeIfSuspended( repo, providers, @@ -1518,11 +1523,13 @@ export function openVmPort(input: { readonly teamIds?: readonly string[]; readonly providerVmId: string; readonly port: number; + /** Caller's CURRENT billing plan; used for the free access window. */ + readonly callerPlanId?: string | null; }) { return Effect.gen(function* () { const repo = yield* VmRepository; const providers = yield* VmProviderGateway; - const vm = yield* requireUserVm(input); + const vm = yield* requireAccessibleUserVm(input); yield* preflightResumeIfSuspended( repo, providers, @@ -1632,6 +1639,8 @@ type OpenAttachEndpointInput = { readonly providerVmId: string; readonly options?: AttachOptions; readonly sessionTitle?: string | null; + /** Caller's CURRENT billing plan; used for the free access window. */ + readonly callerPlanId?: string | null; }; export function openAttachEndpoint(input: OpenAttachEndpointInput) { @@ -1649,6 +1658,8 @@ export function openVmSession(input: { readonly sessionId?: string; readonly attachmentId?: string; readonly title?: string | null; + /** Caller's CURRENT billing plan; used for the free access window. */ + readonly callerPlanId?: string | null; }) { const sessionId = input.sessionId?.trim() || `session-${randomUUID()}`; const attachmentId = input.attachmentId?.trim() || `attach-${randomUUID()}`; @@ -1657,6 +1668,7 @@ export function openVmSession(input: { billingTeamId: input.billingTeamId, teamIds: input.teamIds, providerVmId: input.providerVmId, + callerPlanId: input.callerPlanId, sessionTitle: input.title, options: { requireDaemon: true, @@ -1671,10 +1683,12 @@ export function listVmSessions(input: { readonly billingTeamId?: string | null; readonly teamIds?: readonly string[]; readonly providerVmId: string; + /** Caller's CURRENT billing plan; used for the free access window. */ + readonly callerPlanId?: string | null; }) { return Effect.gen(function* () { const repo = yield* VmRepository; - const vm = yield* requireUserVm(input); + const vm = yield* requireAccessibleUserVm(input); return yield* repo.listVmSessions({ userId: input.userId, vmId: vm.id }); }); } @@ -1683,7 +1697,7 @@ function openAttachEndpointResult(input: OpenAttachEndpointInput) { return Effect.gen(function* () { const repo = yield* VmRepository; const providers = yield* VmProviderGateway; - const vm = yield* requireUserVm(input); + const vm = yield* requireAccessibleUserVm(input); yield* preflightResumeIfSuspended(repo, providers, vm, input.providerVmId, "attach"); // Once preflight records the VM as running, that state is externally // visible to concurrent attach/SSH requests. Later cleanup failures must @@ -1746,11 +1760,13 @@ export function openSshEndpoint(input: { readonly billingTeamId?: string | null; readonly teamIds?: readonly string[]; readonly providerVmId: string; + /** Caller's CURRENT billing plan; used for the free access window. */ + readonly callerPlanId?: string | null; }) { return Effect.gen(function* () { const repo = yield* VmRepository; const providers = yield* VmProviderGateway; - const vm = yield* requireUserVm(input); + const vm = yield* requireAccessibleUserVm(input); yield* preflightResumeIfSuspended(repo, providers, vm, input.providerVmId, "ssh"); yield* revokeActiveIdentities(vm, { failOnCleanupError: true }); const endpoint = yield* withResumeOnSuspendedAfterFailure( @@ -1782,6 +1798,23 @@ export function openSshEndpoint(input: { }); } +/// Access-verb variant of requireUserVm: a free-plan machine older than the +/// free access window is preserved but unreachable until the caller upgrades. +/// List/status/rename/delete deliberately keep using requireUserVm so the +/// machine stays visible and disposable while locked. +function requireAccessibleUserVm(input: ExistingVmAccessInput) { + return Effect.gen(function* () { + const vm = yield* requireUserVm(input); + if (isVmFreeAccessExpired(input.callerPlanId, vm.createdAt ?? undefined)) { + return yield* Effect.fail(new VmFreeAccessExpiredError({ + vmId: input.providerVmId, + windowDays: vmFreeAccessWindowDays(), + })); + } + return vm; + }); +} + function requireUserVm(input: ExistingVmAccessInput) { return Effect.gen(function* () { const repo = yield* VmRepository; diff --git a/web/tests/vm-billing-limit-paywall.test.ts b/web/tests/vm-billing-limit-paywall.test.ts index dbc6e76d3d90..1126d7706b47 100644 --- a/web/tests/vm-billing-limit-paywall.test.ts +++ b/web/tests/vm-billing-limit-paywall.test.ts @@ -1,22 +1,24 @@ import { describe, expect, test } from "bun:test"; import { defaultMemoryMbForPlan, + isVmFreeAccessExpired, maxActiveVmsForPlan, maxMemoryMbForPlan, + vmFreeAccessWindowDays, } from "../services/vms/entitlements"; -import { vmActiveLimitExceededResponse } from "../services/vms/routeHelpers"; +import { vmActiveLimitExceededResponse, vmFreeAccessExpiredResponse } from "../services/vms/routeHelpers"; async function body(response: Response): Promise> { return (await response.json()) as Record; } describe("free plan VM allowance", () => { - test("free users get 3 Cloud VMs by default", () => { - expect(maxActiveVmsForPlan("free", {})).toBe(3); + test("free users get one full-size Cloud VM by default", () => { + expect(maxActiveVmsForPlan("free", {})).toBe(1); }); - test("paid plans keep a higher default allowance", () => { - expect(maxActiveVmsForPlan("pro", {})).toBe(15); + test("pro gets five machines by default", () => { + expect(maxActiveVmsForPlan("pro", {})).toBe(5); }); test("the free allowance stays env-overridable", () => { @@ -25,13 +27,13 @@ describe("free plan VM allowance", () => { }); describe("Cloud VM memory allowance", () => { - test("free defaults to 4 GB and caps at 4 GB", () => { - expect(defaultMemoryMbForPlan("free", {})).toBe(4096); - expect(maxMemoryMbForPlan("free", {})).toBe(4096); + test("free defaults to 24 GB and caps at 24 GB", () => { + expect(defaultMemoryMbForPlan("free", {})).toBe(24576); + expect(maxMemoryMbForPlan("free", {})).toBe(24576); }); - test("paid plans default to 8 GB and cap at 32 GB", () => { - expect(defaultMemoryMbForPlan("pro", {})).toBe(8192); + test("paid plans default to 24 GB and cap at 32 GB", () => { + expect(defaultMemoryMbForPlan("pro", {})).toBe(24576); expect(maxMemoryMbForPlan("pro", {})).toBe(32768); }); @@ -86,3 +88,42 @@ describe("active-limit response as the paywall moment", () => { expect(payload.message).toContain("1 Cloud VM."); }); }); + +describe("free access window", () => { + const days = (n: number) => n * 24 * 60 * 60 * 1000; + const now = 1_800_000_000_000; + + test("defaults to 5 days and stays env-overridable", () => { + expect(vmFreeAccessWindowDays({})).toBe(5); + expect(vmFreeAccessWindowDays({ CMUX_VM_FREE_ACCESS_WINDOW_DAYS: "14" })).toBe(14); + }); + + test("a free machine expires after the window and not before", () => { + expect(isVmFreeAccessExpired("free", now - days(6), {}, now)).toBe(true); + expect(isVmFreeAccessExpired("free", new Date(now - days(6)), {}, now)).toBe(true); + expect(isVmFreeAccessExpired("free", now - days(4), {}, now)).toBe(false); + }); + + test("a paid plan never expires, even for machines created on free", () => { + expect(isVmFreeAccessExpired("pro", now - days(400), {}, now)).toBe(false); + expect(isVmFreeAccessExpired("team", now - days(400), {}, now)).toBe(false); + }); + + test("window 0 disables the gate; unknown createdAt fails open", () => { + expect(isVmFreeAccessExpired("free", now - days(400), { CMUX_VM_FREE_ACCESS_WINDOW_DAYS: "0" }, now)).toBe(false); + expect(isVmFreeAccessExpired("free", null, {}, now)).toBe(false); + }); + + test("the expired response is the upgrade prompt, with delete as the out", async () => { + const response = vmFreeAccessExpiredResponse({ vmId: "noble-wren", windowDays: 5 }); + expect(response.status).toBe(402); + const payload = await body(response); + expect(payload.error).toBe("vm_access_requires_pro"); + expect(payload.message).toContain("5 days"); + expect(payload.message).toContain("preserved"); + expect(String(payload.action)).toContain("https://cmux.com/pricing"); + expect(String(payload.action)).toContain("cmux vm rm noble-wren"); + expect(payload.upgradeRequired).toBe(true); + expect(payload.upgradeUrl).toBe("https://cmux.com/pricing"); + }); +}); diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 13ad500b08f8..b265cd216f73 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -398,12 +398,12 @@ describe("VM REST auth", () => { billingCustomerType: "team", billingTeamId: "team-1", billingPlanId: "pro", - maxActiveVms: 15, + maxActiveVms: 5, provider: "freestyle", image: "snapshot-test", imageVersion: null, idempotencyKey: "idem-1", - memoryMb: 8192, + memoryMb: 24576, })); expect(listTeams).not.toHaveBeenCalled(); expect(runVmWorkflow).toHaveBeenCalled(); @@ -465,7 +465,7 @@ describe("VM REST auth", () => { new Request("https://cmux.test/api/vm", { method: "POST", headers: { origin: "https://cmux.test" }, - body: JSON.stringify({ provider: "freestyle", image: "snapshot-test", memoryMb: 8192 }), + body: JSON.stringify({ provider: "freestyle", image: "snapshot-test", memoryMb: 32768 }), }), ); @@ -473,7 +473,7 @@ describe("VM REST auth", () => { const payload = await response.json(); expect(payload).toMatchObject({ error: "vm_memory_exceeds_plan", - details: { requestedMemoryMb: 8192, maxMemoryMb: 4096, planId: "free" }, + details: { requestedMemoryMb: 32768, maxMemoryMb: 24576, planId: "free" }, }); expect(runVmWorkflow).not.toHaveBeenCalled(); }); @@ -646,7 +646,7 @@ describe("VM REST auth", () => { billingCustomerType: "team", billingTeamId: "team-2", billingPlanId: "free", - maxActiveVms: 3, + maxActiveVms: 1, })); expect(listTeams).toHaveBeenCalledTimes(1); }); @@ -703,7 +703,7 @@ describe("VM REST auth", () => { billingCustomerType: "team", billingTeamId: "team-2", billingPlanId: "free", - maxActiveVms: 3, + maxActiveVms: 1, })); }); @@ -866,7 +866,7 @@ describe("VM REST auth", () => { billingCustomerType: "team", billingTeamId: "team-2", billingPlanId: "team", - maxActiveVms: 15, + maxActiveVms: 5, })); expect(runVmWorkflow).toHaveBeenCalled(); }); @@ -1027,6 +1027,7 @@ describe("VM REST auth", () => { billingTeamId: "team-1", teamIds: ["team-1"], providerVmId: "provider-vm-team-1", + callerPlanId: "pro", }); runVmWorkflow.mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" }); @@ -1043,6 +1044,7 @@ describe("VM REST auth", () => { billingTeamId: "team-1", teamIds: ["team-1"], providerVmId: "provider-vm-team-1", + callerPlanId: "pro", command: "true", timeoutMs: 30_000, }); From 0b22ea798d7387c4b83054f4395f628b58ab43c3 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Tue, 25 Aug 2026 17:55:25 -0700 Subject: [PATCH 02/53] Machines panel: free-window countdowns and locked rows (#10760) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * machines: surface the free access window — countdown rows, locked rows, upgrade routing The list payload now carries freeAccessWindowDays (0 for paid plans) so clients render policy from the wire instead of hardcoding it. The Machines panel mirrors the backend's window math per row: free-plan machines show a days-left countdown in the subtitle, and a machine past the window renders locked — lock glyph in place of the activity dot, Locked in the subtitle, and double-click/context-menu routing to the shared Pro upgrade presenter instead of a doomed connect (the backend still enforces with 402s; the UI just stops walking into them). Rename/Status/Delete stay available on locked rows so the machine remains manageable and disposable. Strings localized en+ja; snapshot window math unit-tested against the backend's boundary behavior. Co-Authored-By: Claude Fable 5 * machines: flip free-window rows at the boundary itself, not on a poll tick Review follow-up: the countdown/lock facet was only as fresh as the 45s list poll. Expiry is a known future timestamp the client can compute (createdAt + window), so the panel now arms a one-shot timer at exactly the next transition across the fleet — each day-boundary where the label decrements, and finally the expiry — and recomputes the facet locally with no network, re-arming for the next boundary. Rows flip at the moment the state changes; the slow poll is left covering only what genuinely needs the server (machines created or deleted elsewhere). The recompute happens above the lazy-list snapshot boundary, so the panel's snapshot rule (cmux#2586) holds. Boundary math unit-tested. Co-Authored-By: Claude Fable 5 --------- Co-authored-by: cmux reload-cloud Co-authored-by: Claude Fable 5 --- Resources/Localizable.xcstrings | 68 ++++++++++++ Sources/Cloud/MachinesPanelView.swift | 58 ++++++++-- Sources/Cloud/MachinesPanelViewModel.swift | 119 +++++++++++++++++++-- Sources/Cloud/VMClient.swift | 7 +- cmuxTests/MachinesPanelModelTests.swift | 113 ++++++++++++++++++- web/app/api/vm/route.ts | 10 +- 6 files changed, 354 insertions(+), 21 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index f402d198bee2..a335ee7d2c77 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -123399,6 +123399,23 @@ } } }, + "machines.menu.upgradeToReconnect": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade to Reconnect\u2026" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "アップグレードして再接続\u2026" + } + } + } + }, "machines.rename.confirm": { "extractionState": "manual", "localizations": { @@ -123484,6 +123501,57 @@ } } }, + "machines.row.dayLeft": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "1 day left" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "残り1日" + } + } + } + }, + "machines.row.daysLeft": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d days left" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "残り%d日" + } + } + } + }, + "machines.row.locked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Locked" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ロック中" + } + } + } + }, "machines.row.openDesktop": { "extractionState": "manual", "localizations": { diff --git a/Sources/Cloud/MachinesPanelView.swift b/Sources/Cloud/MachinesPanelView.swift index a6cceb03583e..14131a4ebbed 100644 --- a/Sources/Cloud/MachinesPanelView.swift +++ b/Sources/Cloud/MachinesPanelView.swift @@ -499,6 +499,9 @@ struct MachineRowActions { let runCommand: @MainActor (String, [String]) -> Void let confirmDelete: @MainActor (String) -> Void let promptRename: @MainActor (String, String?) -> Void + /// A locked (free-window-expired) machine routes here instead of a doomed + /// connect; the backend enforces the same boundary with 402s. + let promptUpgrade: @MainActor () -> Void static func bound( onWillMutate: @escaping @MainActor (String) -> Void = { _ in }, @@ -534,6 +537,9 @@ struct MachineRowActions { }, promptRename: { id, currentLabel in presentRenamePrompt(id: id, currentLabel: currentLabel, onWillMutate: onWillMutate, onDidMutate: onDidMutate) + }, + promptUpgrade: { + ProUpgradePresenter.present() } ) } @@ -762,17 +768,31 @@ private struct MachineRow: View, Equatable { .contentShape(Rectangle()) .background(rowBackground) .onHover { isHovered = $0 } - .onTapGesture(count: 2) { actions.openShell(machine.id) } + .onTapGesture(count: 2) { + if machine.freeAccess == .expired { + actions.promptUpgrade() + } else { + actions.openShell(machine.id) + } + } .help(helpText) .contextMenu { menuItems } .accessibilityElement(children: .combine) .accessibilityLabel("\(machine.displayName), \(machine.activityLabel)") } + @ViewBuilder private var activityDot: some View { - Circle() - .fill(dotColor) - .frame(width: 7, height: 7) + if machine.freeAccess == .expired { + Image(systemName: "lock.fill") + .font(.system(size: 8, weight: .semibold)) + .foregroundColor(.secondary.opacity(0.8)) + .frame(width: 7) + } else { + Circle() + .fill(dotColor) + .frame(width: 7, height: 7) + } } private var rowBackground: some View { @@ -800,6 +820,18 @@ private struct MachineRow: View, Equatable { if let createdAt = machine.createdAt { parts.append(Self.relativeFormatter.localizedString(for: createdAt, relativeTo: Date())) } + switch machine.freeAccess { + case .unrestricted: + break + case .expired: + parts.append(String(localized: "machines.row.locked", defaultValue: "Locked")) + case .active(let daysLeft): + parts.append( + daysLeft == 1 + ? String(localized: "machines.row.dayLeft", defaultValue: "1 day left") + : String(format: String(localized: "machines.row.daysLeft", defaultValue: "%d days left"), daysLeft) + ) + } return parts.joined(separator: " · ") } @@ -809,12 +841,18 @@ private struct MachineRow: View, Equatable { @ViewBuilder private var menuItems: some View { - Button(String(localized: "machines.menu.openShell", defaultValue: "Open Shell")) { - actions.openShell(machine.id) - } - if machine.isDesktop { - Button(String(localized: "machines.menu.openDesktop", defaultValue: "Open Desktop")) { - actions.openDesktop(machine.id) + if machine.freeAccess == .expired { + Button(String(localized: "machines.menu.upgradeToReconnect", defaultValue: "Upgrade to Reconnect\u{2026}")) { + actions.promptUpgrade() + } + } else { + Button(String(localized: "machines.menu.openShell", defaultValue: "Open Shell")) { + actions.openShell(machine.id) + } + if machine.isDesktop { + Button(String(localized: "machines.menu.openDesktop", defaultValue: "Open Desktop")) { + actions.openDesktop(machine.id) + } } } Divider() diff --git a/Sources/Cloud/MachinesPanelViewModel.swift b/Sources/Cloud/MachinesPanelViewModel.swift index 7f4a39241666..c750c384564d 100644 --- a/Sources/Cloud/MachinesPanelViewModel.swift +++ b/Sources/Cloud/MachinesPanelViewModel.swift @@ -18,6 +18,19 @@ struct MachineSnapshot: Equatable, Identifiable { case attention(String) } + /// Where a machine stands in the free plan's access window. The backend is + /// the enforcement point (402 on access verbs); this mirrors it so the row + /// can show the countdown and route a locked machine to the upgrade flow + /// instead of a doomed connect. + enum FreeAccessState: Equatable { + /// Paid plan, or the window is disabled server-side. + case unrestricted + /// Reachable, with this many whole-or-partial days remaining. + case active(daysLeft: Int) + /// Past the window: preserved but locked until the plan is upgraded. + case expired + } + let id: String let provider: String let image: String @@ -26,6 +39,8 @@ struct MachineSnapshot: Equatable, Identifiable { let createdAt: Date? /// User-chosen label; nil when the machine has no label. let label: String? + /// Free-plan access window position; `.unrestricted` on paid plans. + var freeAccess: FreeAccessState = .unrestricted /// Latest activity reading; nil until the first sample lands. var stats: VMStats? @@ -60,21 +75,75 @@ struct MachinePlanSnapshot: Equatable { } enum MachineSnapshotBuilder { - static func snapshot(from summary: VMSummary) -> MachineSnapshot { - MachineSnapshot( + static func snapshot( + from summary: VMSummary, + freeAccessWindowDays: Int = 0, + now: Date = Date() + ) -> MachineSnapshot { + let createdAt = summary.createdAt > 0 + ? Date(timeIntervalSince1970: TimeInterval(summary.createdAt) / 1000) + : nil + return MachineSnapshot( id: summary.id, provider: summary.provider, image: summary.image, isDesktop: summary.image.contains("xfce-vnc"), activity: activity(fromStatus: summary.status), - createdAt: summary.createdAt > 0 - ? Date(timeIntervalSince1970: TimeInterval(summary.createdAt) / 1000) - : nil, + createdAt: createdAt, label: summary.displayName, + freeAccess: freeAccessState(createdAt: createdAt, windowDays: freeAccessWindowDays, now: now), stats: nil ) } + /// Mirrors the backend's window math (created + windowDays vs now); the + /// backend stays the enforcement point, this only drives the row UI. + static func freeAccessState( + createdAt: Date?, + windowDays: Int, + now: Date = Date() + ) -> MachineSnapshot.FreeAccessState { + guard windowDays > 0, let createdAt else { return .unrestricted } + let remaining = createdAt.addingTimeInterval(TimeInterval(windowDays) * 86_400).timeIntervalSince(now) + if remaining <= 0 { return .expired } + return .active(daysLeft: Int((remaining / 86_400).rounded(.up))) + } + + /// The next instant at which a machine's free-access presentation changes: + /// each day-boundary where the "N days left" label decrements, and finally + /// the expiry itself. Nil once expired (or when no window applies) — there + /// is nothing left to wait for. Expiry is a *known future timestamp*, so + /// the panel arms a one-shot timer at exactly this instant instead of + /// discovering the transition on a poll sweep. + static func nextFreeAccessTransition( + createdAt: Date?, + windowDays: Int, + now: Date = Date() + ) -> Date? { + guard windowDays > 0, let createdAt else { return nil } + let expiry = createdAt.addingTimeInterval(TimeInterval(windowDays) * 86_400) + let remaining = expiry.timeIntervalSince(now) + guard remaining > 0 else { return nil } + let daysLeft = Int((remaining / 86_400).rounded(.up)) + // The label decrements when remaining crosses (daysLeft - 1) whole days; + // for the final day that crossing IS the expiry. + return expiry.addingTimeInterval(-TimeInterval(daysLeft - 1) * 86_400) + } + + /// Recomputes only the free-access facet of existing snapshots against a + /// fresh clock — no network, stats and identity preserved. + static func applyingFreeAccess( + to snapshots: [MachineSnapshot], + windowDays: Int, + now: Date = Date() + ) -> [MachineSnapshot] { + snapshots.map { snapshot in + var next = snapshot + next.freeAccess = freeAccessState(createdAt: snapshot.createdAt, windowDays: windowDays, now: now) + return next + } + } + static func activity(fromStatus status: String) -> MachineSnapshot.Activity { switch status.lowercased() { case "running", "ready", "standby", "paused": @@ -124,6 +193,12 @@ final class MachinesPanelViewModel: ObservableObject { private var refreshTask: Task? private var pollTask: Task? private var statsTask: Task? + /// One-shot timer armed at the exact next free-access transition (a + /// countdown day-boundary or an expiry). Expiry is client-computable from + /// createdAt + window, so rows flip at the boundary itself — scheduling, + /// not polling; the slow poll only covers changes made elsewhere. + private var freeAccessTransitionTask: Task? + private var freeAccessWindowDays = 0 private var authSignOutObserver: NSObjectProtocol? private static let statsInterval: Duration = .seconds(20) @@ -197,6 +272,30 @@ final class MachinesPanelViewModel: ObservableObject { pollTask = nil statsTask?.cancel() statsTask = nil + freeAccessTransitionTask?.cancel() + freeAccessTransitionTask = nil + } + + /// Sleeps until the earliest upcoming transition across the fleet, then + /// recomputes the free-access facet locally and re-arms for the next one. + private func scheduleFreeAccessTransition(now: Date = Date()) { + freeAccessTransitionTask?.cancel() + freeAccessTransitionTask = nil + guard freeAccessWindowDays > 0 else { return } + let windowDays = freeAccessWindowDays + let next = machines + .compactMap { MachineSnapshotBuilder.nextFreeAccessTransition(createdAt: $0.createdAt, windowDays: windowDays, now: now) } + .min() + guard let next else { return } + // A hair past the boundary so the recompute lands on the new side. + let delay = max(next.timeIntervalSince(now), 0) + 0.5 + freeAccessTransitionTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled, let self else { return } + let now = Date() + self.machines = MachineSnapshotBuilder.applyingFreeAccess(to: self.machines, windowDays: windowDays, now: now) + self.scheduleFreeAccessTransition(now: now) + } } /// Drop every locally cached machine and in-flight sample when auth ends. @@ -208,6 +307,9 @@ final class MachinesPanelViewModel: ObservableObject { refreshTask = nil statsTask?.cancel() statsTask = nil + freeAccessTransitionTask?.cancel() + freeAccessTransitionTask = nil + freeAccessWindowDays = 0 machines = [] plan = nil activeOperation = nil @@ -224,11 +326,16 @@ final class MachinesPanelViewModel: ObservableObject { do { let page = try await client.listPage() let previous = Dictionary(uniqueKeysWithValues: machines.map { ($0.id, $0.stats) }) - var snapshots = page.vms.map(MachineSnapshotBuilder.snapshot(from:)) + let freeAccessWindowDays = page.limits?.freeAccessWindowDays ?? 0 + self.freeAccessWindowDays = freeAccessWindowDays + var snapshots = page.vms.map { + MachineSnapshotBuilder.snapshot(from: $0, freeAccessWindowDays: freeAccessWindowDays) + } for index in snapshots.indices { snapshots[index].stats = previous[snapshots[index].id] ?? nil } machines = snapshots + scheduleFreeAccessTransition() refreshStats() plan = MachineSnapshotBuilder.planSnapshot(activeCount: snapshots.count, limits: page.limits) lastErrorDescription = nil diff --git a/Sources/Cloud/VMClient.swift b/Sources/Cloud/VMClient.swift index 56b2028efbd0..b6ed309fbbff 100644 --- a/Sources/Cloud/VMClient.swift +++ b/Sources/Cloud/VMClient.swift @@ -269,6 +269,8 @@ struct VMSummary { struct VMPlanLimits { let maxActiveVms: Int let planId: String + /// Days a free-plan machine stays reachable after creation; 0 = no window. + let freeAccessWindowDays: Int } struct VMListPage { @@ -445,7 +447,10 @@ actor VMClient { if let rawLimits = obj["limits"] as? [String: Any], let maxActiveVms = (rawLimits["maxActiveVms"] as? Int) ?? (rawLimits["maxActiveVms"] as? NSNumber)?.intValue, let planId = rawLimits["planId"] as? String { - limits = VMPlanLimits(maxActiveVms: maxActiveVms, planId: planId) + let freeAccessWindowDays = (rawLimits["freeAccessWindowDays"] as? Int) + ?? (rawLimits["freeAccessWindowDays"] as? NSNumber)?.intValue + ?? 0 + limits = VMPlanLimits(maxActiveVms: maxActiveVms, planId: planId, freeAccessWindowDays: freeAccessWindowDays) } let vms = try items.enumerated().map { index, dict -> VMSummary in guard let id = dict["id"] as? String, !id.isEmpty else { diff --git a/cmuxTests/MachinesPanelModelTests.swift b/cmuxTests/MachinesPanelModelTests.swift index fac197738598..21a566fe09a1 100644 --- a/cmuxTests/MachinesPanelModelTests.swift +++ b/cmuxTests/MachinesPanelModelTests.swift @@ -75,20 +75,20 @@ final class MachinesPanelModelTests: XCTestCase { let underLimit = MachineSnapshotBuilder.planSnapshot( activeCount: 2, - limits: VMPlanLimits(maxActiveVms: 3, planId: "free") + limits: VMPlanLimits(maxActiveVms: 3, planId: "free", freeAccessWindowDays: 5) ) XCTAssertEqual(underLimit?.isAtLimit, false) XCTAssertEqual(underLimit?.isPaidPlan, false) let atLimit = MachineSnapshotBuilder.planSnapshot( activeCount: 3, - limits: VMPlanLimits(maxActiveVms: 3, planId: "free") + limits: VMPlanLimits(maxActiveVms: 3, planId: "free", freeAccessWindowDays: 5) ) XCTAssertEqual(atLimit?.isAtLimit, true) let paid = MachineSnapshotBuilder.planSnapshot( activeCount: 4, - limits: VMPlanLimits(maxActiveVms: 10, planId: "pro") + limits: VMPlanLimits(maxActiveVms: 10, planId: "pro", freeAccessWindowDays: 0) ) XCTAssertEqual(paid?.isAtLimit, false) XCTAssertEqual(paid?.isPaidPlan, true) @@ -137,4 +137,111 @@ final class MachinesPanelModelTests: XCTestCase { CloudVMPanelAuthState.signedIn.allowsAuthenticatedOperation ) } + + func testFreeAccessStateMirrorsTheBackendWindow() { + let created = Date(timeIntervalSince1970: 1_787_400_000) + let day: TimeInterval = 86_400 + + // Paid plan / disabled window (0 days) never restricts. + XCTAssertEqual( + MachineSnapshotBuilder.freeAccessState(createdAt: created, windowDays: 0, now: created.addingTimeInterval(400 * day)), + .unrestricted + ) + // Unknown createdAt fails open, matching the backend. + XCTAssertEqual( + MachineSnapshotBuilder.freeAccessState(createdAt: nil, windowDays: 5, now: Date()), + .unrestricted + ) + // Inside the window: partial days round up so day one reads "5 days left". + XCTAssertEqual( + MachineSnapshotBuilder.freeAccessState(createdAt: created, windowDays: 5, now: created.addingTimeInterval(1)), + .active(daysLeft: 5) + ) + XCTAssertEqual( + MachineSnapshotBuilder.freeAccessState(createdAt: created, windowDays: 5, now: created.addingTimeInterval(4.5 * day)), + .active(daysLeft: 1) + ) + // Past the window: locked. + XCTAssertEqual( + MachineSnapshotBuilder.freeAccessState(createdAt: created, windowDays: 5, now: created.addingTimeInterval(5 * day + 1)), + .expired + ) + } + + func testNextFreeAccessTransitionIsTheExactBoundary() { + let created = Date(timeIntervalSince1970: 1_787_400_000) + let day: TimeInterval = 86_400 + + // Fresh machine: the first label decrement is one day in. + XCTAssertEqual( + MachineSnapshotBuilder.nextFreeAccessTransition(createdAt: created, windowDays: 5, now: created.addingTimeInterval(1)), + created.addingTimeInterval(day) + ) + // Mid-window: next transition is the next whole-day crossing. + XCTAssertEqual( + MachineSnapshotBuilder.nextFreeAccessTransition(createdAt: created, windowDays: 5, now: created.addingTimeInterval(3.5 * day)), + created.addingTimeInterval(4 * day) + ) + // Final day: the next transition IS the expiry. + XCTAssertEqual( + MachineSnapshotBuilder.nextFreeAccessTransition(createdAt: created, windowDays: 5, now: created.addingTimeInterval(4.5 * day)), + created.addingTimeInterval(5 * day) + ) + // Expired or unwindowed: nothing left to wait for. + XCTAssertNil( + MachineSnapshotBuilder.nextFreeAccessTransition(createdAt: created, windowDays: 5, now: created.addingTimeInterval(6 * day)) + ) + XCTAssertNil( + MachineSnapshotBuilder.nextFreeAccessTransition(createdAt: created, windowDays: 0, now: created) + ) + XCTAssertNil( + MachineSnapshotBuilder.nextFreeAccessTransition(createdAt: nil, windowDays: 5, now: created) + ) + } + + func testApplyingFreeAccessRecomputesOnlyThatFacet() { + let created = 1_787_400_000_000 + let summary = VMSummary( + id: "noble-wren", + provider: "blaxel", + status: "running", + image: "blaxel/xfce-vnc:latest", + createdAt: created, + base: nil + ) + let createdDate = Date(timeIntervalSince1970: TimeInterval(created) / 1000) + let before = MachineSnapshotBuilder.snapshot( + from: summary, + freeAccessWindowDays: 5, + now: createdDate.addingTimeInterval(4.9 * 86_400) + ) + XCTAssertEqual(before.freeAccess, .active(daysLeft: 1)) + + let after = MachineSnapshotBuilder.applyingFreeAccess( + to: [before], + windowDays: 5, + now: createdDate.addingTimeInterval(5 * 86_400 + 1) + ) + XCTAssertEqual(after.count, 1) + XCTAssertEqual(after[0].freeAccess, .expired) + XCTAssertEqual(after[0].id, before.id) + XCTAssertEqual(after[0].stats, before.stats) + } + + func testSnapshotCarriesFreeAccessState() { + let created = 1_787_400_000_000 + let summary = VMSummary( + id: "noble-wren", + provider: "blaxel", + status: "running", + image: "blaxel/xfce-vnc:latest", + createdAt: created, + base: nil + ) + let now = Date(timeIntervalSince1970: TimeInterval(created) / 1000 + 6 * 86_400) + let snapshot = MachineSnapshotBuilder.snapshot(from: summary, freeAccessWindowDays: 5, now: now) + XCTAssertEqual(snapshot.freeAccess, .expired) + let unrestricted = MachineSnapshotBuilder.snapshot(from: summary, freeAccessWindowDays: 0, now: now) + XCTAssertEqual(unrestricted.freeAccess, .unrestricted) + } } diff --git a/web/app/api/vm/route.ts b/web/app/api/vm/route.ts index bad8ae7dd7c8..124c900d8dd2 100644 --- a/web/app/api/vm/route.ts +++ b/web/app/api/vm/route.ts @@ -21,10 +21,12 @@ import { } from "../../../services/vms/errors"; import { defaultMemoryMbForPlan, + isPaidVmPlan, isVmBillingTeamResolutionError, isVmProGateBlocked, maxMemoryMbForPlan, resolveVmEntitlements, + vmFreeAccessWindowDays, } from "../../../services/vms/entitlements"; import { imageUsesBakedFreestyleSignedAdmin, @@ -109,8 +111,14 @@ export async function GET(request: Request): Promise { listEntitlements = null; } } + // freeAccessWindowDays is 0 for paid plans (no window) so clients can + // render countdowns/locks from the payload without hardcoding policy. const limits = listEntitlements - ? { maxActiveVms: listEntitlements.maxActiveVms, planId: listEntitlements.planId } + ? { + maxActiveVms: listEntitlements.maxActiveVms, + planId: listEntitlements.planId, + freeAccessWindowDays: isPaidVmPlan(listEntitlements.planId) ? 0 : vmFreeAccessWindowDays(), + } : undefined; return jsonResponse({ vms, limits }); }, From 190e9e2961fac5d65444677465bc1d92b771064e Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 25 Aug 2026 22:04:11 -0700 Subject: [PATCH 03/53] worktree: drop stored defaults on identity lets so Xcode 26.6 builds main After #10781, worktreeDeviceID/worktreeFileID were both defaulted at the declaration and assigned in the explicit init, which the current toolchain rejects ("immutable value may only be initialized once"). The init's parameter defaults keep the same call-site contract. Co-Authored-By: Claude Fable 5 --- Sources/ExtensionWorktreePrototype.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/ExtensionWorktreePrototype.swift b/Sources/ExtensionWorktreePrototype.swift index 457b8d9d12a6..486e0c59a850 100644 --- a/Sources/ExtensionWorktreePrototype.swift +++ b/Sources/ExtensionWorktreePrototype.swift @@ -12,8 +12,8 @@ struct CmuxExtensionWorktreeCreationResult: Sendable { let generatedArtifactContents: Data /// Filesystem identity captured immediately after `git worktree add`. /// Rollback refuses to touch a path whose checkout was replaced. - let worktreeDeviceID: UInt64? = nil - let worktreeFileID: UInt64? = nil + let worktreeDeviceID: UInt64? + let worktreeFileID: UInt64? /// A convenience command (e.g. a sample dev-server launcher) that should run /// inside the new workspace's interactive shell. This is *setup*, never the /// workspace's primary process. From 2960d95912e55617e024fca1a2a779a085ac79f5 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Tue, 25 Aug 2026 22:04:11 -0700 Subject: [PATCH 04/53] cli: cmux vm run/push/pull/wait and the cmux-cloud-vm agent skill vm run routes a command to a cloud machine without naming one: sticky per-directory binding, then an idle agent-pool machine, then a sleeper, then a freshly provisioned pool machine. push/pull move files over the exec channel (base64 chunks, SHA-256 verified, directories as tarballs); wait blocks until ready and optionally wakes the machine. The skill lets any coding agent drive machines from plain CLI. Co-Authored-By: Claude Fable 5 --- .claude/skills/cmux-cloud-vm | 1 + CLAUDE.md | 1 + CLI/CMUXCLI+VMTransfer.swift | 963 ++++++++++++++++++ CLI/cmux.swift | 41 +- Resources/Localizable.xcstrings | 68 ++ Sources/TerminalController.swift | 11 + cmux.xcodeproj/project.pbxproj | 8 + cmuxTests/CLIVMTransferTests.swift | 538 ++++++++++ docs/cli-contract.md | 8 +- docs/internal/machine-router.md | 49 + skills/cmux-cloud-vm/SKILL.md | 75 ++ skills/cmux-cloud-vm/agents/openai.yaml | 4 + .../references/agent-workflows.md | 85 ++ skills/cmux-cloud-vm/references/commands.md | 92 ++ 14 files changed, 1937 insertions(+), 7 deletions(-) create mode 120000 .claude/skills/cmux-cloud-vm create mode 100644 CLI/CMUXCLI+VMTransfer.swift create mode 100644 cmuxTests/CLIVMTransferTests.swift create mode 100644 docs/internal/machine-router.md create mode 100644 skills/cmux-cloud-vm/SKILL.md create mode 100644 skills/cmux-cloud-vm/agents/openai.yaml create mode 100644 skills/cmux-cloud-vm/references/agent-workflows.md create mode 100644 skills/cmux-cloud-vm/references/commands.md diff --git a/.claude/skills/cmux-cloud-vm b/.claude/skills/cmux-cloud-vm new file mode 120000 index 000000000000..533238c4ee4a --- /dev/null +++ b/.claude/skills/cmux-cloud-vm @@ -0,0 +1 @@ +../../skills/cmux-cloud-vm \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index b98664f55350..a429a36a59b0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,7 @@ Detailed contributor rules live in `skills/`. Use the task-specific skill before - `cmux-architecture`: package boundaries, file/API discipline, testability, Swift concurrency. - `cmux-backend`: backend TypeScript, Effect, Cloud VM control plane, provider secrets, Postgres and migrations. - `cmux-billing`: Stripe checkout, entitlements, webhooks, pricing dev stack, live provisioning. +- `cmux-cloud-vm`: driving cmux Cloud machines from the CLI (`cmux vm` exec/push/pull/wait, ports, checkpoints, forks) and the agent etiquette around them. - `cmux-debugging`: debug event log, Debug menu, runtime pitfalls, typing-sensitive paths, SwiftUI list boundaries. - `cmux-localization`: user-facing strings, localization files, shortcut text, localization audit. - `cmux-testing`: regression policy, Swift Testing, test quality, test wiring, local vs CI validation. diff --git a/CLI/CMUXCLI+VMTransfer.swift b/CLI/CMUXCLI+VMTransfer.swift new file mode 100644 index 000000000000..e4f80f4b9f52 --- /dev/null +++ b/CLI/CMUXCLI+VMTransfer.swift @@ -0,0 +1,963 @@ +import CryptoKit +import Foundation + +/// `cmux vm push` / `cmux vm pull` / `cmux vm wait` — file transfer and readiness +/// primitives for cloud machines, built entirely on the existing `vm.exec` and +/// `vm.status` socket methods so they work against every provider that supports +/// exec, with no daemon or SSH requirement on the machine. +/// +/// Transfer strategy: files move as base64 chunks inside `vm.exec` commands. +/// Each chunk is one round trip, so throughput is bounded by the exec path, but +/// the primitive works on a machine that only has a shell + coreutils. Both +/// directions verify a SHA-256 digest end to end (falling back to a byte-count +/// check when the machine has no `sha256sum`). Directories travel as tarballs +/// and extract on the far side. +extension CMUXCLI { + /// Raw bytes per exec round trip. Base64 expands this ~4/3, staying well + /// under control-plane request/response body limits. + static let vmTransferChunkBytes = 512 * 1024 + /// Hard cap for a single push/pull. Exec-chunked transfer is the wrong tool + /// past this size; the error message points at better tools. + static let vmTransferMaxBytes = 256 * 1024 * 1024 + static let vmTransferExecTimeoutMs = 100_000 + static let vmTransferExecResponseTimeout: TimeInterval = 120 + /// Directory entries skipped by default on `vm push` of a directory. Every + /// entry here is cheap to recreate on the machine (installs, build output) + /// or meaningless there (VCS internals, OS litter); `--no-default-excludes` + /// sends everything. + static let vmPushDefaultExcludes = [ + ".git", + "node_modules", + ".venv", + "__pycache__", + ".DS_Store", + ] + + static var vmPushUsage: String { + """ + Usage: cmux vm push [remote-path] [--exclude ]... [--no-default-excludes] + + Copy a local file or directory onto a cloud machine over the exec channel + (no SSH needed). Directories travel as tarballs; by default \(vmPushDefaultExcludes.joined(separator: ", ")) + are skipped. The remote path defaults to the local basename in the exec + working directory (the machine user's home). + + Examples: + cmux vm push brave-otter ./script.sh + cmux vm push brave-otter ./myrepo work/myrepo + cmux vm push brave-otter ./site --exclude dist + """ + } + + static var vmPullUsage: String { + """ + Usage: cmux vm pull [local-path] + + Copy a file or directory from a cloud machine to the local disk over the + exec channel. The local path defaults to the remote basename in the + current directory. + + Examples: + cmux vm pull brave-otter work/report.pdf + cmux vm pull brave-otter /var/log/app ./app-logs + """ + } + + static var vmWaitUsage: String { + """ + Usage: cmux vm wait [--timeout ] [--wake] + + Block until the machine reports a ready status (running, ready, standby, + or paused). --wake additionally runs a trivial exec so a sleeping machine + is awake when the command returns. Exits non-zero on timeout or when the + machine reaches a failed state. Default timeout: 180 seconds. + """ + } + + // MARK: - push + + func runVMPushCommand(rest: [String], client: SocketClient, jsonOutput: Bool, quiet: Bool = false) throws { + if rest.contains("--help") || rest.contains("-h") { + print(Self.vmPushUsage) + return + } + var positional: [String] = [] + var extraExcludes: [String] = [] + var useDefaultExcludes = true + var index = 0 + while index < rest.count { + let arg = rest[index] + switch arg { + case "--exclude": + guard index + 1 < rest.count else { + throw CLIError(message: "--exclude requires a pattern\n\n\(Self.vmPushUsage)") + } + extraExcludes.append(rest[index + 1]) + index += 2 + case "--no-default-excludes": + useDefaultExcludes = false + index += 1 + default: + guard !arg.hasPrefix("--") else { + throw CLIError(message: "Unknown option \(arg)\n\n\(Self.vmPushUsage)") + } + positional.append(arg) + index += 1 + } + } + guard positional.count >= 2, positional.count <= 3 else { + throw CLIError(message: Self.vmPushUsage) + } + let vmID = positional[0] + let localPath = (positional[1] as NSString).expandingTildeInPath + let localURL = URL(fileURLWithPath: localPath) + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: localURL.path, isDirectory: &isDirectory) else { + throw CLIError(message: "No such local path: \(localPath)") + } + + let started = Date() + let remotePath = positional.count == 3 ? positional[2] : localURL.lastPathComponent + let payloadData: Data + var stagingTarURL: URL? + var appliedExcludes: [String] = [] + if isDirectory.boolValue { + let excludes = (useDefaultExcludes ? Self.vmPushDefaultExcludes : []) + extraExcludes + appliedExcludes = excludes + let tarURL = try makeLocalTarball(of: localURL, excludes: excludes) + stagingTarURL = tarURL + payloadData = try Data(contentsOf: tarURL) + } else { + payloadData = try Data(contentsOf: localURL) + } + defer { + if let stagingTarURL { + try? FileManager.default.removeItem(at: stagingTarURL) + } + } + guard payloadData.count <= Self.vmTransferMaxBytes else { + throw CLIError(message: """ + \(localPath) is \(Self.formatByteCount(payloadData.count)) after packing; \ + vm push caps out at \(Self.formatByteCount(Self.vmTransferMaxBytes)). \ + For big trees, clone or download inside the machine instead: + cmux vm exec \(vmID) -- git clone + cmux vm exec \(vmID) -- curl -LO + """) + } + + let localDigest = SHA256.hash(data: payloadData).map { String(format: "%02x", $0) }.joined() + let remoteStaging: String + let extractDestination: String? + if isDirectory.boolValue { + remoteStaging = "/tmp/cmux-push-\(UUID().uuidString.prefix(8)).tgz" + extractDestination = remotePath + } else { + remoteStaging = remotePath + ".cmux-partial-\(UUID().uuidString.prefix(8))" + extractDestination = nil + } + + try uploadData( + payloadData, + to: remoteStaging, + finalDestination: extractDestination == nil ? remotePath : nil, + vmID: vmID, + expectedDigest: localDigest, + client: client + ) + + if let extractDestination { + let quotedTar = shellQuote(remoteStaging) + let quotedDest = shellQuote(extractDestination) + let extract = "mkdir -p \(quotedDest) && tar -xzf \(quotedTar) -C \(quotedDest) && rm -f \(quotedTar)" + let response = try vmTransferExec(command: extract, vmID: vmID, client: client) + try requireExecSuccess(response, context: "extracting \(remoteStaging) into \(extractDestination)") + } + + let seconds = Int(Date().timeIntervalSince(started).rounded()) + if jsonOutput { + var payload: [String: Any] = [ + "ok": true, + "direction": "push", + "vm": vmID, + "local": localPath, + "remote": remotePath, + "kind": isDirectory.boolValue ? "directory" : "file", + "bytes": payloadData.count, + "sha256": localDigest, + "seconds": seconds, + ] + if !appliedExcludes.isEmpty { + payload["excluded"] = appliedExcludes + } + print(jsonString(payload)) + return + } + let template = CMUXDiffViewerLocalization.string( + "cli.vm.push.summary", + defaultValue: "Pushed %1$@ to %2$@:%3$@ (%4$@)" + ) + let summary = String(format: template, localPath, vmID, remotePath, Self.formatByteCount(payloadData.count)) + var notes: [String] = [] + if !appliedExcludes.isEmpty { + let excludedTemplate = CMUXDiffViewerLocalization.string( + "cli.vm.push.excludedNote", + defaultValue: "Skipped: %1$@ (pass --no-default-excludes to send everything)" + ) + notes.append(String(format: excludedTemplate, appliedExcludes.joined(separator: ", "))) + } + // `vm run` embeds pushes: stdout stays reserved for the command's own + // output, so the transfer summary goes to stderr instead. + for line in [summary] + notes { + if quiet { + cliWriteStderr(line + "\n") + } else { + print(line) + } + } + } + + // MARK: - pull + + func runVMPullCommand(rest: [String], client: SocketClient, jsonOutput: Bool, quiet: Bool = false) throws { + if rest.contains("--help") || rest.contains("-h") { + print(Self.vmPullUsage) + return + } + let positional = rest.filter { !$0.hasPrefix("--") } + guard positional.count == rest.count else { + let unknown = rest.first { $0.hasPrefix("--") } ?? "" + throw CLIError(message: "Unknown option \(unknown)\n\n\(Self.vmPullUsage)") + } + guard positional.count >= 2, positional.count <= 3 else { + throw CLIError(message: Self.vmPullUsage) + } + let vmID = positional[0] + let remotePath = positional[1] + let remoteBasename = (remotePath as NSString).lastPathComponent + let localPath = (positional.count == 3 ? positional[2] : remoteBasename) + let localURL = URL(fileURLWithPath: (localPath as NSString).expandingTildeInPath) + + let started = Date() + let quotedRemote = shellQuote(remotePath) + let statCommand = "p=\(quotedRemote); if [ -d \"$p\" ]; then echo CMUX_DIR; elif [ -f \"$p\" ]; then echo CMUX_FILE; else echo CMUX_MISSING; fi" + let statResponse = try vmTransferExec(command: statCommand, vmID: vmID, client: client) + try requireExecSuccess(statResponse, context: "inspecting \(remotePath)") + let statOut = ((statResponse["stdout"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + + let sourcePath: String + var remoteTarToCleanUp: String? + let isDirectory: Bool + switch statOut { + case "CMUX_DIR": + isDirectory = true + let tarPath = "/tmp/cmux-pull-\(UUID().uuidString.prefix(8)).tgz" + let packCommand = "tar -czf \(shellQuote(tarPath)) -C \(quotedRemote) ." + let packResponse = try vmTransferExec(command: packCommand, vmID: vmID, client: client) + try requireExecSuccess(packResponse, context: "packing \(remotePath)") + sourcePath = tarPath + remoteTarToCleanUp = tarPath + case "CMUX_FILE": + isDirectory = false + sourcePath = remotePath + case "CMUX_MISSING": + throw CLIError(message: "No such path on \(vmID): \(remotePath)") + default: + throw CLIError(message: "Could not inspect \(remotePath) on \(vmID): \(statOut)") + } + defer { + if let remoteTarToCleanUp { + _ = try? vmTransferExec(command: "rm -f \(shellQuote(remoteTarToCleanUp))", vmID: vmID, client: client) + } + } + + let data = try downloadData(from: sourcePath, vmID: vmID, client: client) + let digest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + + if isDirectory { + let stagingTar = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-pull-\(UUID().uuidString.prefix(8)).tgz") + try data.write(to: stagingTar, options: [.atomic]) + defer { try? FileManager.default.removeItem(at: stagingTar) } + try FileManager.default.createDirectory(at: localURL, withIntermediateDirectories: true) + let untar = Process() + untar.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + untar.arguments = ["-xzf", stagingTar.path, "-C", localURL.path] + try cliRunProcess(untar) + untar.waitUntilExit() + guard untar.terminationStatus == 0 else { + throw CLIError(message: "tar failed extracting into \(localURL.path) (exit \(untar.terminationStatus))") + } + } else { + let parent = localURL.deletingLastPathComponent() + try FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true) + try data.write(to: localURL, options: [.atomic]) + } + + let seconds = Int(Date().timeIntervalSince(started).rounded()) + if jsonOutput { + let payload: [String: Any] = [ + "ok": true, + "direction": "pull", + "vm": vmID, + "remote": remotePath, + "local": localURL.path, + "kind": isDirectory ? "directory" : "file", + "bytes": data.count, + "sha256": digest, + "seconds": seconds, + ] + print(jsonString(payload)) + return + } + let template = CMUXDiffViewerLocalization.string( + "cli.vm.pull.summary", + defaultValue: "Pulled %1$@:%2$@ to %3$@ (%4$@)" + ) + let summary = String(format: template, vmID, remotePath, localURL.path, Self.formatByteCount(data.count)) + if quiet { + cliWriteStderr(summary + "\n") + } else { + print(summary) + } + } + + // MARK: - wait + + func runVMWaitCommand(rest: [String], client: SocketClient, jsonOutput: Bool) throws { + if rest.contains("--help") || rest.contains("-h") { + print(Self.vmWaitUsage) + return + } + var vmID: String? + var timeoutSeconds = 180 + var wake = false + var index = 0 + while index < rest.count { + let arg = rest[index] + switch arg { + case "--timeout": + guard index + 1 < rest.count, let parsed = Int(rest[index + 1]), parsed > 0 else { + throw CLIError(message: "--timeout requires a positive number of seconds\n\n\(Self.vmWaitUsage)") + } + timeoutSeconds = parsed + index += 2 + case "--wake": + wake = true + index += 1 + default: + guard !arg.hasPrefix("--") else { + throw CLIError(message: "Unknown option \(arg)\n\n\(Self.vmWaitUsage)") + } + guard vmID == nil else { + throw CLIError(message: Self.vmWaitUsage) + } + vmID = arg + index += 1 + } + } + guard let vmID else { + throw CLIError(message: Self.vmWaitUsage) + } + + let readiness = try waitForVMReady(vmID: vmID, timeoutSeconds: timeoutSeconds, client: client) + let lastStatus = readiness.status + let statusPayload = readiness.payload + let started = Date().addingTimeInterval(-TimeInterval(readiness.waitedSeconds)) + + if wake { + let response = try vmTransferExec(command: "true", vmID: vmID, client: client) + try requireExecSuccess(response, context: "waking \(vmID)") + } + + let seconds = Int(Date().timeIntervalSince(started).rounded()) + if jsonOutput { + var payload = statusPayload + payload["ok"] = true + payload["waited_seconds"] = seconds + payload["woke"] = wake + print(jsonString(payload)) + return + } + let template = CMUXDiffViewerLocalization.string( + "cli.vm.wait.ready", + defaultValue: "%1$@ is ready (%2$@) after %3$ds" + ) + print(String(format: template, vmID, lastStatus, seconds)) + } + + /// Polls `vm.status` until the machine reports a ready status. Ready/pending + /// sets mirror MachineSnapshotBuilder.activity in the app, so the CLI and the + /// Machines panel agree on what "ready" means. + @discardableResult + func waitForVMReady( + vmID: String, + timeoutSeconds: Int, + client: SocketClient + ) throws -> (status: String, payload: [String: Any], waitedSeconds: Int) { + let readyStatuses: Set = ["running", "ready", "standby", "paused"] + let pendingStatuses: Set = ["creating", "starting", "pending", "resuming", "unknown"] + + let started = Date() + let deadline = started.addingTimeInterval(TimeInterval(timeoutSeconds)) + while true { + let response = try client.sendV2(method: "vm.status", params: ["id": vmID], responseTimeout: 60) + let status = ((response["status"] as? String) ?? "unknown").lowercased() + if readyStatuses.contains(status) { + return (status, response, Int(Date().timeIntervalSince(started).rounded())) + } + guard pendingStatuses.contains(status) else { + throw CLIError(message: "\(vmID) reached status \"\(status)\" — it will not become ready. Try `cmux vm status \(vmID)`.") + } + guard Date() < deadline else { + throw CLIError(message: "Timed out after \(timeoutSeconds)s waiting for \(vmID) (last status: \(status)). Re-run with --timeout to wait longer.") + } + Thread.sleep(forTimeInterval: 3) + } + } + + // MARK: - transfer plumbing + + private func vmTransferExec( + command: String, + vmID: String, + client: SocketClient + ) throws -> [String: Any] { + try client.sendV2( + method: "vm.exec", + params: [ + "id": vmID, + "command": command, + "timeout_ms": Self.vmTransferExecTimeoutMs, + ], + responseTimeout: Self.vmTransferExecResponseTimeout + ) + } + + private func requireExecSuccess(_ response: [String: Any], context: String) throws { + let exitCode = (response["exit_code"] as? Int) ?? -1 + guard exitCode == 0 else { + let stderr = ((response["stderr"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + let detail = stderr.isEmpty ? "" : ": \(stderr)" + throw CLIError(message: "Command failed while \(context) (exit \(exitCode))\(detail)") + } + } + + /// Streams `data` to `stagingPath` on the machine in base64 chunks, then — + /// when `finalDestination` is set — atomically moves it into place. Verifies + /// SHA-256 (or size when the machine lacks `sha256sum`) either way. + private func uploadData( + _ data: Data, + to stagingPath: String, + finalDestination: String?, + vmID: String, + expectedDigest: String, + client: SocketClient + ) throws { + let quotedStaging = shellQuote(stagingPath) + var initCommand = ": > \(quotedStaging)" + if let finalDestination { + let parent = (finalDestination as NSString).deletingLastPathComponent + if !parent.isEmpty { + initCommand = "mkdir -p \(shellQuote(parent)) && " + initCommand + } + } + let initResponse = try vmTransferExec(command: initCommand, vmID: vmID, client: client) + try requireExecSuccess(initResponse, context: "preparing \(stagingPath)") + + let totalChunks = max(1, (data.count + Self.vmTransferChunkBytes - 1) / Self.vmTransferChunkBytes) + var offset = 0 + var chunkIndex = 0 + while offset < data.count { + let end = min(offset + Self.vmTransferChunkBytes, data.count) + let chunk = data.subdata(in: offset.. 1 { + cliWriteStderr("\rcmux vm push: \(chunkIndex)/\(totalChunks) chunks") + if chunkIndex == totalChunks { + cliWriteStderr("\n") + } + } + } + + let verifyTarget: String + var finalizeCommand = "" + if let finalDestination { + finalizeCommand = "mv \(quotedStaging) \(shellQuote(finalDestination)) && " + verifyTarget = finalDestination + } else { + verifyTarget = stagingPath + } + let quotedVerify = shellQuote(verifyTarget) + finalizeCommand += "if command -v sha256sum >/dev/null 2>&1; then sha256sum \(quotedVerify); else wc -c < \(quotedVerify); fi" + let finalizeResponse = try vmTransferExec(command: finalizeCommand, vmID: vmID, client: client) + try requireExecSuccess(finalizeResponse, context: "finalizing \(verifyTarget)") + let stdout = ((finalizeResponse["stdout"] as? String) ?? "").trimmingCharacters(in: .whitespacesAndNewlines) + try Self.verifyTransferIntegrity( + report: stdout, + expectedDigest: expectedDigest, + expectedBytes: data.count, + subject: "\(vmID):\(verifyTarget)" + ) + } + + /// Reads a remote file back in base64 chunks, verifying against a digest + /// taken on the machine before the transfer starts. + private func downloadData(from remotePath: String, vmID: String, client: SocketClient) throws -> Data { + let quoted = shellQuote(remotePath) + let precheck = "wc -c < \(quoted) && (command -v sha256sum >/dev/null 2>&1 && sha256sum \(quoted) || true)" + let precheckResponse = try vmTransferExec(command: precheck, vmID: vmID, client: client) + try requireExecSuccess(precheckResponse, context: "sizing \(remotePath)") + let precheckLines = ((precheckResponse["stdout"] as? String) ?? "") + .split(separator: "\n") + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + guard let sizeLine = precheckLines.first, let totalBytes = Int(sizeLine) else { + throw CLIError(message: "Could not size \(remotePath) on \(vmID)") + } + guard totalBytes <= Self.vmTransferMaxBytes else { + throw CLIError(message: """ + \(vmID):\(remotePath) is \(Self.formatByteCount(totalBytes)); \ + vm pull caps out at \(Self.formatByteCount(Self.vmTransferMaxBytes)). \ + Push the data somewhere directly from the machine instead, e.g.: + cmux vm exec \(vmID) -- gh release upload ... \(remotePath) + """) + } + let remoteDigest = precheckLines.count > 1 ? precheckLines[1].split(separator: " ").first.map(String.init) : nil + + var data = Data() + data.reserveCapacity(totalBytes) + let totalChunks = max(1, (totalBytes + Self.vmTransferChunkBytes - 1) / Self.vmTransferChunkBytes) + for chunkIndex in 0.. 1 { + cliWriteStderr("\rcmux vm pull: \(chunkIndex + 1)/\(totalChunks) chunks") + if chunkIndex + 1 == totalChunks { + cliWriteStderr("\n") + } + } + } + + guard data.count == totalBytes else { + throw CLIError(message: "Pulled \(data.count) bytes from \(vmID):\(remotePath) but expected \(totalBytes)") + } + if let remoteDigest { + let localDigest = SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + guard localDigest == remoteDigest else { + throw CLIError(message: "Digest mismatch pulling \(vmID):\(remotePath) — expected \(remoteDigest), got \(localDigest)") + } + } + return data + } + + // MARK: - vm run (the machine router) + + /// Label that marks a machine as belonging to the router's pool. `vm run` + /// only reuses and scales machines carrying this label, so it never runs + /// agent workloads on a machine the user set up by hand. + static let vmRunPoolLabel = "agent-pool" + static let vmRunDefaultTimeoutSeconds = 600 + static let vmRunCreateWaitSeconds = 300 + /// An awake pool machine above this CPU load is treated as busy and only + /// used when the pool cannot grow (plan limit). + static let vmRunBusyCPUPercent = 60.0 + + static var vmRunUsage: String { + """ + Usage: cmux vm run [--sync] [--pull ] [--machine ] [--new] [--size <2g|4g|8g|16g|32g>] [--timeout ] -- + + Run a command on a cloud machine without naming one: reuses an idle + machine from the router pool (label "\(vmRunPoolLabel)"), wakes a sleeping + one, or provisions a fresh machine when the pool is empty or busy — + then executes the command and passes its exit code through. + + Options: + --sync Push the current directory to work/ first + and run the command there. + --pull After the command, pull that path back into the + current directory. + --machine Skip routing and use this machine. + --new Force a fresh pool machine. + --size Memory preset for a machine this run creates. + --timeout Command timeout (default \(vmRunDefaultTimeoutSeconds)s, max 15 minutes). + + Examples: + cmux vm run -- uname -a + cmux vm run --sync -- bun test + cmux vm run --sync --pull work/app/dist -- sh -c 'cd work/app && bun run build' + """ + } + + func runVMRunCommand(rest: [String], client: SocketClient, jsonOutput: Bool) throws { + if rest.contains("--help") || rest.contains("-h") { + print(Self.vmRunUsage) + return + } + var flags: [String] = rest + var commandArgv: [String] = [] + if let separator = rest.firstIndex(of: "--") { + flags = Array(rest[.. String { + guard index + 1 < flags.count else { + throw CLIError(message: "\(arg) requires a value\n\n\(Self.vmRunUsage)") + } + index += 1 + return flags[index] + } + switch arg { + case "--sync": + sync = true + case "--new": + forceNew = true + case "--pull": + pullPath = try takeValue() + case "--machine": + machineOverride = try takeValue() + case "--size": + sizeOption = try takeValue() + case "--timeout": + let raw = try takeValue() + guard let parsed = Int(raw), parsed > 0 else { + throw CLIError(message: "--timeout requires a positive number of seconds\n\n\(Self.vmRunUsage)") + } + timeoutSeconds = parsed + default: + throw CLIError(message: "Unknown option \(arg)\n\n\(Self.vmRunUsage)") + } + index += 1 + } + guard !commandArgv.isEmpty else { + throw CLIError(message: Self.vmRunUsage) + } + var memoryMb: Int? + if let sizeOption { + guard let parsed = Self.parseCloudVMSize(sizeOption) else { + throw CLIError(message: "vm run: unknown size '\(sizeOption)'. Sizes: 2g, 4g, 8g, 16g, 32g (or memory in MB).") + } + memoryMb = parsed + } + + let started = Date() + let selection = try selectVMForRun( + machineOverride: machineOverride, + forceNew: forceNew, + memoryMb: memoryMb, + client: client + ) + cliWriteStderr("[cmux vm run] \(selection.id) (\(selection.reason))\n") + Self.saveVMRunBinding( + workKey: Self.vmRunWorkKey(forDirectory: FileManager.default.currentDirectoryPath), + machine: selection.id + ) + + var syncedRemoteDir: String? + var commandPrefix = "" + if sync { + let cwd = FileManager.default.currentDirectoryPath + let basename = (cwd as NSString).lastPathComponent + let remoteDir = "work/\(basename)" + try runVMPushCommand( + rest: [selection.id, cwd, remoteDir], + client: client, + jsonOutput: false, + quiet: true + ) + syncedRemoteDir = remoteDir + commandPrefix = "cd \(shellQuote(remoteDir)) && " + } + + let command = commandPrefix + commandArgv.map(shellQuote).joined(separator: " ") + let clampedTimeoutMs = min(timeoutSeconds * 1000, 15 * 60 * 1000) + let response = try client.sendV2( + method: "vm.exec", + params: [ + "id": selection.id, + "command": command, + "timeout_ms": clampedTimeoutMs, + ], + responseTimeout: TimeInterval(timeoutSeconds + 60) + ) + let stdout = (response["stdout"] as? String) ?? "" + let stderr = (response["stderr"] as? String) ?? "" + let exitCode = (response["exit_code"] as? Int) ?? -1 + + var pulledTo: String? + if let pullPath, exitCode == 0 { + let localName = (pullPath as NSString).lastPathComponent + try runVMPullCommand( + rest: [selection.id, pullPath, localName], + client: client, + jsonOutput: false, + quiet: true + ) + pulledTo = localName + } + + let seconds = Int(Date().timeIntervalSince(started).rounded()) + if jsonOutput { + var payload: [String: Any] = [ + "ok": exitCode == 0, + "machine": selection.id, + "created": selection.created, + "exit_code": exitCode, + "stdout": stdout, + "stderr": stderr, + "seconds": seconds, + ] + if let syncedRemoteDir { payload["synced_to"] = syncedRemoteDir } + if let pulledTo { payload["pulled_to"] = pulledTo } + print(jsonString(payload)) + if exitCode != 0 { + throw CLIError(message: "exit \(exitCode)", exitCode: exitCode > 0 ? Int32(exitCode) : 1) + } + return + } + if !stdout.isEmpty { print(stdout, terminator: stdout.hasSuffix("\n") ? "" : "\n") } + if !stderr.isEmpty { + cliWriteStderr(stderr) + if !stderr.hasSuffix("\n") { cliWriteStderr("\n") } + } + if exitCode != 0 { + throw CLIError(message: "exit \(exitCode)", exitCode: exitCode > 0 ? Int32(exitCode) : 1) + } + } + + struct VMRunSelection { + let id: String + let created: Bool + let reason: String + } + + /// Sticky work→machine bindings, keyed by the caller's directory. Reusing + /// the machine that last ran this directory's work keeps its warm state — + /// the synced checkout, installed dependencies, build caches — which is what + /// makes routing feel invisible. Mirrors the sticky-assignment pattern in + /// the coderouter credential pool. + struct VMRunBinding: Codable { + let machine: String + let updatedAtUnix: Int + } + + static let vmRunBindingTTLSeconds = 14 * 24 * 3600 + + static func vmRunBindingsStoreURL() -> URL { + // NSHomeDirectory honors $HOME, so tests (and other redirected runs) + // get an isolated binding store instead of writing the user's. + URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true) + .appendingPathComponent(".cmuxterm", isDirectory: true) + .appendingPathComponent("vm-run-bindings.json", isDirectory: false) + } + + static func vmRunWorkKey(forDirectory path: String) -> String { + let canonical = URL(fileURLWithPath: path).standardizedFileURL.path + let digest = SHA256.hash(data: Data(canonical.utf8)) + return digest.prefix(8).map { String(format: "%02x", $0) }.joined() + } + + static func loadVMRunBindings(from url: URL? = nil) -> [String: VMRunBinding] { + let storeURL = url ?? vmRunBindingsStoreURL() + guard let data = try? Data(contentsOf: storeURL), + let store = try? JSONDecoder().decode([String: VMRunBinding].self, from: data) else { + return [:] + } + let cutoff = Int(Date().timeIntervalSince1970) - vmRunBindingTTLSeconds + return store.filter { $0.value.updatedAtUnix >= cutoff } + } + + static func saveVMRunBinding(workKey: String, machine: String, to url: URL? = nil) { + let storeURL = url ?? vmRunBindingsStoreURL() + var store = loadVMRunBindings(from: storeURL) + store[workKey] = VMRunBinding(machine: machine, updatedAtUnix: Int(Date().timeIntervalSince1970)) + guard let data = try? JSONEncoder().encode(store) else { return } + try? FileManager.default.createDirectory( + at: storeURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try? data.write(to: storeURL, options: [.atomic]) + } + + /// Routing policy, in order: + /// 1. `--machine` bypasses routing entirely. + /// 2. Awake pool machines under the busy threshold, least-loaded first. + /// 3. Sleeping pool machines (exec wakes them). + /// 4. Provision a fresh pool machine (unless the plan is at its cap). + /// 5. At the plan cap: the least-loaded busy pool machine. + /// Pool membership is the "\(vmRunPoolLabel)" label; the router never touches + /// machines the user created and named themselves. + private func selectVMForRun( + machineOverride: String?, + forceNew: Bool, + memoryMb: Int?, + client: SocketClient + ) throws -> VMRunSelection { + if let machineOverride { + return VMRunSelection(id: machineOverride, created: false, reason: "pinned with --machine") + } + + let readyStatuses: Set = ["running", "ready", "standby", "paused"] + var idleAwake: [(id: String, cpu: Double)] = [] + var asleep: [String] = [] + var busy: [(id: String, cpu: Double)] = [] + + if !forceNew { + let listResponse = try client.sendV2(method: "vm.list", responseTimeout: 60) + let vms = (listResponse["vms"] as? [[String: Any]]) ?? [] + let pool = vms.filter { vm in + let label = (vm["displayName"] as? String) ?? "" + let status = ((vm["status"] as? String) ?? "").lowercased() + return label == Self.vmRunPoolLabel && readyStatuses.contains(status) + } + // Sticky binding beats load: the machine that last ran this + // directory's work holds its synced checkout and installed deps. + let workKey = Self.vmRunWorkKey(forDirectory: FileManager.default.currentDirectoryPath) + if let binding = Self.loadVMRunBindings()[workKey], + pool.contains(where: { ($0["id"] as? String) == binding.machine }) { + return VMRunSelection(id: binding.machine, created: false, reason: "reused, warm machine for this directory") + } + for vm in pool { + guard let id = vm["id"] as? String else { continue } + guard let stats = try? client.sendV2(method: "vm.stats", params: ["id": id], responseTimeout: 60) else { + // A machine that cannot report stats is still usable — treat + // it like a sleeper rather than dropping it from the pool. + asleep.append(id) + continue + } + let state = ((stats["state"] as? String) ?? "").lowercased() + if state == "asleep" { + asleep.append(id) + continue + } + let cpu = (stats["cpu_percent"] as? Double) ?? Double(stats["cpu_percent"] as? Int ?? 0) + if cpu < Self.vmRunBusyCPUPercent { + idleAwake.append((id, cpu)) + } else { + busy.append((id, cpu)) + } + } + if let best = idleAwake.min(by: { $0.cpu < $1.cpu }) { + return VMRunSelection(id: best.id, created: false, reason: "reused, awake and idle") + } + if let sleeper = asleep.first { + return VMRunSelection(id: sleeper, created: false, reason: "reused, waking from sleep") + } + } + + do { + let id = try createPoolVM(memoryMb: memoryMb, client: client) + return VMRunSelection(id: id, created: true, reason: "provisioned a fresh pool machine") + } catch let error as CLIError { + if error.vmBackendCode == "vm_active_limit_exceeded", let leastBusy = busy.min(by: { $0.cpu < $1.cpu }) { + return VMRunSelection(id: leastBusy.id, created: false, reason: "plan at machine cap; sharing the least-loaded pool machine") + } + throw error + } + } + + private func createPoolVM(memoryMb: Int?, client: SocketClient) throws -> String { + var params: [String: Any] = [ + "image": Self.cloudVMBaseImage, + "persistent_home": true, + "per_machine_home": true, + // Fresh key per run: a failed create is simply retried by the next + // `vm run`, and the interactive `vm new` store stays untouched. + "idempotency_key": UUID().uuidString, + ] + if let memoryMb { params["memory_mb"] = memoryMb } + let response = try client.sendV2( + method: "vm.create", + params: params, + responseTimeout: Self.vmCreateResponseTimeoutSeconds + ) + guard let id = response["id"] as? String, !id.isEmpty else { + throw CLIError(message: "vm run: create returned no machine id") + } + // Label failures are cosmetic (the machine still works this run), but an + // unlabeled machine would leak out of the pool, so surface the problem. + do { + _ = try client.sendV2( + method: "vm.rename", + params: ["id": id, "display_name": Self.vmRunPoolLabel], + responseTimeout: 60 + ) + } catch { + cliWriteStderr("[cmux vm run] warning: could not label \(id) as \(Self.vmRunPoolLabel); future runs will not reuse it\n") + } + try waitForVMReady(vmID: id, timeoutSeconds: Self.vmRunCreateWaitSeconds, client: client) + return id + } + + /// `report` is either `" "` (sha256sum) or a bare byte + /// count (wc -c fallback). + static func verifyTransferIntegrity( + report: String, + expectedDigest: String, + expectedBytes: Int, + subject: String + ) throws { + let firstToken = report.split(separator: " ").first.map(String.init) ?? "" + if firstToken.count == 64, firstToken.range(of: "^[0-9a-f]{64}$", options: .regularExpression) != nil { + guard firstToken == expectedDigest else { + throw CLIError(message: "Digest mismatch on \(subject) — expected \(expectedDigest), machine reports \(firstToken)") + } + return + } + if let reportedBytes = Int(firstToken) { + guard reportedBytes == expectedBytes else { + throw CLIError(message: "Size mismatch on \(subject) — expected \(expectedBytes) bytes, machine reports \(reportedBytes)") + } + return + } + throw CLIError(message: "Could not verify \(subject): unexpected report \"\(report)\"") + } + + private func makeLocalTarball(of directory: URL, excludes: [String]) throws -> URL { + let tarURL = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-push-\(UUID().uuidString.prefix(8)).tgz") + var arguments = ["-czf", tarURL.path, "-C", directory.path] + for pattern in excludes { + arguments.append("--exclude") + arguments.append(pattern) + } + arguments.append(".") + let tar = Process() + tar.executableURL = URL(fileURLWithPath: "/usr/bin/tar") + tar.arguments = arguments + try cliRunProcess(tar) + tar.waitUntilExit() + guard tar.terminationStatus == 0 else { + throw CLIError(message: "tar failed packing \(directory.path) (exit \(tar.terminationStatus))") + } + return tarURL + } + + static func formatByteCount(_ bytes: Int) -> String { + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6073dfeeb5a4..b86a9439c597 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -3170,7 +3170,9 @@ struct CMUXCLI { let simulatorOwnedCommandRunner: any SimulatorOwnedCommandRunning private static let vmCreateIdempotencyTTLSeconds: TimeInterval = 10 * 60 - private static let vmCreateResponseTimeoutSeconds: TimeInterval = 16 * 60 + // Internal (not private): `vm run` in CMUXCLI+VMTransfer.swift provisions + // pool machines with the same create timeout. + static let vmCreateResponseTimeoutSeconds: TimeInterval = 16 * 60 private static let vmAttachResponseTimeoutSeconds: TimeInterval = 16 * 60 private static let sshPTYTerminalConnectedResponseTimeoutSeconds: TimeInterval = 0.5 private static let sshPTYTerminalConnectedRetryDelaySeconds: TimeInterval = 0.1 @@ -3188,7 +3190,9 @@ struct CMUXCLI { /// Blaxel image that boots an xfce desktop with a noVNC web front end. private static let cloudVMDesktopImage = "blaxel/xfce-vnc:latest" /// Shell-only image for `vm new --base`; the backend default is the desktop image. - private static let cloudVMBaseImage = "blaxel/base-image:latest" + /// Internal (not private) so `vm run` in CMUXCLI+VMTransfer.swift provisions + /// pool machines from the same image. + static let cloudVMBaseImage = "blaxel/base-image:latest" /// `--size` spellings → memory in MB. vCPUs scale with memory on Blaxel. private static let cloudVMSizeAliases: [String: Int] = [ "2g": 2048, "2gb": 2048, "small": 2048, @@ -4990,6 +4994,18 @@ struct CMUXCLI { throw CLIError(message: "exit \(exitCode)") } + case "run": + try runVMRunCommand(rest: rest, client: client, jsonOutput: jsonOutput) + + case "push", "upload": + try runVMPushCommand(rest: rest, client: client, jsonOutput: jsonOutput) + + case "pull", "download": + try runVMPullCommand(rest: rest, client: client, jsonOutput: jsonOutput) + + case "wait": + try runVMWaitCommand(rest: rest, client: client, jsonOutput: jsonOutput) + case "tools", "tool-inspector": guard let vmId = rest.first else { throw CLIError(message: "Usage: cmux vm tools ") @@ -5052,7 +5068,7 @@ struct CMUXCLI { default: throw CLIError(message: """ - Usage: cmux \(command) [args...] + Usage: cmux \(command) [args...] Common commands: cmux vm ls @@ -5060,6 +5076,8 @@ struct CMUXCLI { cmux vm status cmux vm snapshot cmux vm fork + cmux vm exec -- + cmux vm push cmux vm ssh cmux vm rm """) @@ -16597,7 +16615,7 @@ struct CMUXCLI { """ case "vm", "cloud": return """ - Usage: cmux \(command) [args...] + Usage: cmux \(command) [args...] Manage cloud VMs. `cloud` is an alias for `vm`. Requires `cmux auth login`. @@ -16640,6 +16658,19 @@ struct CMUXCLI { exposes SSH. rm Destroy a VM. exec -- Run a shell command inside the VM and print stdout. + run [--sync] [--pull ] [--machine ] [--new] -- + Run a command without naming a machine: the router + reuses an idle pool machine, wakes a sleeper, or + provisions a fresh one, then passes the exit code through. + push [remote] [--exclude ]... [--no-default-excludes] + Copy a local file or directory onto the VM over the + exec channel (no SSH needed). Alias: `upload`. + pull [local] + Copy a file or directory from the VM to local disk. + Alias: `download`. + wait [--timeout ] [--wake] + Block until the VM reports a ready status; --wake also + runs a trivial exec so a sleeping machine is awake. tools Inspect installed tools inside the VM. ports Show listening TCP ports inside the VM. handoff Print a short attach handoff block. @@ -37304,7 +37335,7 @@ export default CMUXSessionRestore; auth login | logout (aliases for auth login/logout) \(localizedCoderouterAliases()) - vm [args...] (alias: cloud) + vm [args...] (alias: cloud) remotes [--route ] [--tag ] [--json] (alias: remote) ai-accounts [--team ] [--json] rpc [json-params] diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 8163d49633d9..f1c044979b49 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -57426,6 +57426,57 @@ } } }, + "cli.vm.pull.summary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pulled %1$@:%2$@ to %3$@ (%4$@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@:%2$@ を %3$@ にプルしました(%4$@)" + } + } + } + }, + "cli.vm.push.excludedNote": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Skipped: %1$@ (pass --no-default-excludes to send everything)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "スキップ: %1$@(すべて送るには --no-default-excludes を指定)" + } + } + } + }, + "cli.vm.push.summary": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pushed %1$@ to %2$@:%3$@ (%4$@)" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ を %2$@:%3$@ にプッシュしました(%4$@)" + } + } + } + }, "cli.vm.reconnecting": { "extractionState": "manual", "localizations": { @@ -57488,6 +57539,23 @@ } } }, + "cli.vm.wait.ready": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ is ready (%2$@) after %3$ds" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ は準備完了です(%2$@、%3$d 秒)" + } + } + } + }, "cli.workspace.create.error.envFileRequiresValue": { "extractionState": "manual", "localizations": { diff --git a/Sources/TerminalController.swift b/Sources/TerminalController.swift index 33f1549af7e6..a4054ace12ce 100644 --- a/Sources/TerminalController.swift +++ b/Sources/TerminalController.swift @@ -2807,10 +2807,21 @@ class TerminalController { "auth.sign_out", "vm.list", "vm.create", + "vm.base_open", + "vm.base_reset", + "vm.status", + "vm.stats", + "vm.rename", + "vm.snapshot", + "vm.fork", + "vm.restore", "vm.destroy", "vm.exec", + "vm.open_port", "vm.attach_info", "vm.ssh_info", + "vm.sessions", + "vm.session_attach_info", "aiAccounts.list", "aiAccounts.upload", "aiAccounts.remove", diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 841140c51638..ab3dabf8525d 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -589,6 +589,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources C12984000000000000000004 /* CLIStdioSIGPIPERegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C12984000000000000000003 /* CLIStdioSIGPIPERegressionTests.swift */; }; B05553B10000000000000001 /* CLITmuxCompatRemoteSplitTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */; }; 7837E0057837E0057837E005 /* CLITmuxCompatResizePaneTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7837E0067837E0067837E006 /* CLITmuxCompatResizePaneTests.swift */; }; + A5D4120DA1B2C3D4E5F60F01 /* CLIVMTransferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5D4120EA1B2C3D4E5F60F02 /* CLIVMTransferTests.swift */; }; 773600000000000000000001 /* CLIWindowCommandMockServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 773600000000000000000002 /* CLIWindowCommandMockServer.swift */; }; 773600000000000000000003 /* CLIWindowHandleRoutingTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 773600000000000000000004 /* CLIWindowHandleRoutingTests.swift */; }; 846600000000000000000001 /* CLIWorkspaceGroupSafetyMockServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 846600000000000000000002 /* CLIWorkspaceGroupSafetyMockServer.swift */; }; @@ -735,6 +736,7 @@ C0DE71B10000000000000001 /* AppDelegate+AgentChatNotifications.swift in Sources B9000044A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000045A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift */; }; B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000032A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift */; }; D1FF52000000000000000001 /* CMUXCLI+TypedDiffViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1FF52000000000000000002 /* CMUXCLI+TypedDiffViewer.swift */; }; + CC0033E15A1B2C3D4E5F0101 /* CMUXCLI+VMTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC0033E15A1B2C3D4E5F0102 /* CMUXCLI+VMTransfer.swift */; }; 06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */; }; C0DE31390000000000000105 /* CMUXCLIErrorOutputRegressionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */; }; 906900000000000000000004 /* CMUXCLIMemoryAttributionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 906900000000000000000003 /* CMUXCLIMemoryAttributionTests.swift */; }; @@ -3500,6 +3502,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = C12984000000000000000003 /* CLIStdioSIGPIPERegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIStdioSIGPIPERegressionTests.swift; sourceTree = ""; }; B05553B10000000000000002 /* CLITmuxCompatRemoteSplitTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatRemoteSplitTests.swift; sourceTree = ""; }; 7837E0067837E0067837E006 /* CLITmuxCompatResizePaneTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLITmuxCompatResizePaneTests.swift; sourceTree = ""; }; + A5D4120EA1B2C3D4E5F60F02 /* CLIVMTransferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIVMTransferTests.swift; sourceTree = ""; }; 773600000000000000000002 /* CLIWindowCommandMockServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIWindowCommandMockServer.swift; sourceTree = ""; }; 773600000000000000000004 /* CLIWindowHandleRoutingTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIWindowHandleRoutingTests.swift; sourceTree = ""; }; 846600000000000000000002 /* CLIWorkspaceGroupSafetyMockServer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CLIWorkspaceGroupSafetyMockServer.swift; sourceTree = ""; }; @@ -3632,6 +3635,7 @@ C0DE71B10000000000000002 /* AppDelegate+AgentChatNotifications.swift */ = {isa = B9000045A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TmuxCompatSupport.swift"; sourceTree = ""; }; B9000032A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TopRendering.swift"; sourceTree = ""; }; D1FF52000000000000000002 /* CMUXCLI+TypedDiffViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+TypedDiffViewer.swift"; sourceTree = ""; }; + CC0033E15A1B2C3D4E5F0102 /* CMUXCLI+VMTransfer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+VMTransfer.swift"; sourceTree = ""; }; 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+WorkspaceTodo.swift"; sourceTree = ""; }; C0DE31390000000000000106 /* CMUXCLIErrorOutputRegressionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXCLIErrorOutputRegressionTests.swift; sourceTree = ""; }; 906900000000000000000003 /* CMUXCLIMemoryAttributionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXCLIMemoryAttributionTests.swift; sourceTree = ""; }; @@ -7815,6 +7819,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */, 7E5D35CC62B0F252F9EC2AD1 /* CMUXCLI+WorkspaceTodo.swift */, CC0033E15A1B2C3D4E5F0002 /* CMUXCLI+Comments.swift */, + CC0033E15A1B2C3D4E5F0102 /* CMUXCLI+VMTransfer.swift */, B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */, B9000060A1B2C3D4E5F60719 /* CMUXCLI+HermesAgentHooks.swift */, 8A87C45F990549A8A0A6EE01 /* AgentSurfaceResumeBindingClearOutcome.swift */, @@ -8562,6 +8567,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */, A5D4120CA1B2C3D4E5F60718 /* CLILegacyHookAliasTests.swift */, A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */, + A5D4120EA1B2C3D4E5F60F02 /* CLIVMTransferTests.swift */, C0DE35530000000000000102 /* BundledCLILinkageTests.swift */, C37800000000000000000004 /* VMDefaultCloudCommandTests.swift */, C37800000000000000000002 /* VMSSHCommandTests.swift */, @@ -11059,6 +11065,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef B9000044A1B2C3D4E5F60719 /* CMUXCLI+TmuxCompatSupport.swift in Sources */, B9000033A1B2C3D4E5F60719 /* CMUXCLI+TopRendering.swift in Sources */, D1FF52000000000000000001 /* CMUXCLI+TypedDiffViewer.swift in Sources */, + CC0033E15A1B2C3D4E5F0101 /* CMUXCLI+VMTransfer.swift in Sources */, 06CC2F6C1340C7424D1C7E0A /* CMUXCLI+WorkspaceTodo.swift in Sources */, 918100000000000000000051 /* CodexHookFailureCandidate.swift in Sources */, 918100000000000000000041 /* CodexHookFailureSummary.swift in Sources */, @@ -11338,6 +11345,7 @@ B8B056D80000000000000002 /* MobileHostIdentityTests.swift */ = {isa = PBXFileRef C12984000000000000000004 /* CLIStdioSIGPIPERegressionTests.swift in Sources */, B05553B10000000000000001 /* CLITmuxCompatRemoteSplitTests.swift in Sources */, 7837E0057837E0057837E005 /* CLITmuxCompatResizePaneTests.swift in Sources */, + A5D4120DA1B2C3D4E5F60F01 /* CLIVMTransferTests.swift in Sources */, 773600000000000000000001 /* CLIWindowCommandMockServer.swift in Sources */, 773600000000000000000003 /* CLIWindowHandleRoutingTests.swift in Sources */, 846600000000000000000001 /* CLIWorkspaceGroupSafetyMockServer.swift in Sources */, diff --git a/cmuxTests/CLIVMTransferTests.swift b/cmuxTests/CLIVMTransferTests.swift new file mode 100644 index 000000000000..d5fa2e5e4c38 --- /dev/null +++ b/cmuxTests/CLIVMTransferTests.swift @@ -0,0 +1,538 @@ +import CryptoKit +import Darwin +import Foundation +import XCTest + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +/// End-to-end coverage for `cmux vm push` / `cmux vm pull` / `cmux vm wait`: +/// the real CLI binary runs against a mock control socket that plays the app's +/// side of the `vm.exec` / `vm.status` protocol, so the tests exercise argument +/// parsing, chunked base64 framing, and digest verification exactly as an agent +/// would hit them. +extension CLINotifyProcessIntegrationRegressionTests { + /// Thread-safe byte accumulator for chunks arriving on mock-server threads. + final class VMTransferMockState: @unchecked Sendable { + private let lock = NSLock() + private var storage = Data() + private var counter = 0 + + func append(_ data: Data) { + lock.lock() + storage.append(data) + lock.unlock() + } + + func bytes() -> Data { + lock.lock() + defer { lock.unlock() } + return storage + } + + func nextCount() -> Int { + lock.lock() + defer { lock.unlock() } + counter += 1 + return counter + } + } + + private func vmExecOKResponse(id: String, stdout: String) -> String { + v2Response(id: id, ok: true, result: ["exit_code": 0, "stdout": stdout, "stderr": ""]) + } + + private static func sha256Hex(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } + + func testVMPushFileStreamsChunksAndVerifiesDigest() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("vm-push") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let received = VMTransferMockState() + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + // Three chunks at the CLI's 512 KiB chunk size. + var payload = Data(count: 1_200_000) + payload.withUnsafeMutableBytes { buffer in + for index in buffer.indices { + buffer[index] = UInt8((index &* 31) & 0xFF) + } + } + let expectedDigest = Self.sha256Hex(payload) + + let tempDir = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vm-push-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDir) } + let localFile = tempDir.appendingPathComponent("payload.bin") + try payload.write(to: localFile) + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + if line.hasPrefix("auth ") { return "OK" } + guard let request = self.jsonObject(line), + let id = request["id"] as? String, + let method = request["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + guard method == "vm.exec", + let params = request["params"] as? [String: Any], + let command = params["command"] as? String else { + return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "Unexpected method \(method)"]) + } + if command.hasPrefix(": > ") { + return self.vmExecOKResponse(id: id, stdout: "") + } + if command.contains("| base64 -d >>") { + guard let start = command.range(of: "printf %s '"), + let end = command.range(of: "' | base64 -d >>") else { + return self.v2Response(id: id, ok: false, error: ["code": "bad_chunk", "message": "Unparseable chunk command"]) + } + let encoded = String(command[start.upperBound.. String { + let canonical = URL(fileURLWithPath: path).standardizedFileURL.path + let digest = SHA256.hash(data: Data(canonical.utf8)) + return digest.prefix(8).map { String(format: "%02x", $0) }.joined() + } + + func testVMRunReusesIdlePoolMachine() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("vm-run-reuse") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let isolatedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vm-run-home-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: isolatedHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: isolatedHome) } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + if line.hasPrefix("auth ") { return "OK" } + guard let request = self.jsonObject(line), + let id = request["id"] as? String, + let method = request["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + switch method { + case "vm.list": + return self.v2Response(id: id, ok: true, result: [ + "vms": [ + ["id": "pool-1", "displayName": "agent-pool", "status": "running", "provider": "blaxel", "image": "blaxel/base-image:latest"], + ["id": "user-vm", "displayName": "my precious", "status": "running", "provider": "blaxel", "image": "blaxel/base-image:latest"], + ], + ]) + case "vm.stats": + return self.v2Response(id: id, ok: true, result: ["id": "pool-1", "state": "awake", "cpu_percent": 4.0]) + case "vm.exec": + let params = request["params"] as? [String: Any] + let vmID = (params?["id"] as? String) ?? "?" + guard vmID == "pool-1" else { + return self.v2Response(id: id, ok: false, error: ["code": "wrong_machine", "message": "routed to \(vmID)"]) + } + return self.vmExecOKResponse(id: id, stdout: "routed\n") + default: + return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "Unexpected method \(method)"]) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["HOME"] = isolatedHome.path + + let result = runProcess( + executablePath: cliPath, + arguments: ["vm", "run", "--", "echo", "routed"], + environment: environment, + timeout: 30 + ) + + wait(for: [serverHandled], timeout: 30) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, "stdout=\(result.stdout) stderr=\(result.stderr)") + XCTAssertEqual(result.stdout, "routed\n") + XCTAssertTrue(result.stderr.contains("pool-1"), "router should say which machine it used: \(result.stderr)") + XCTAssertFalse( + state.snapshot().contains { $0.contains(#""method":"vm.create""#) }, + "an idle pool machine must be reused, not a new one created" + ) + } + + func testVMRunProvisionsPoolMachineWhenPoolEmpty() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("vm-run-create") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let isolatedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vm-run-home-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory(at: isolatedHome, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: isolatedHome) } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + if line.hasPrefix("auth ") { return "OK" } + guard let request = self.jsonObject(line), + let id = request["id"] as? String, + let method = request["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + switch method { + case "vm.list": + return self.v2Response(id: id, ok: true, result: ["vms": []]) + case "vm.create": + return self.v2Response(id: id, ok: true, result: ["id": "fresh-1", "provider": "blaxel", "status": "creating", "image": "blaxel/base-image:latest"]) + case "vm.rename": + return self.v2Response(id: id, ok: true, result: ["id": "fresh-1", "displayName": "agent-pool"]) + case "vm.status": + return self.v2Response(id: id, ok: true, result: ["id": "fresh-1", "provider": "blaxel", "status": "running"]) + case "vm.exec": + let params = request["params"] as? [String: Any] + let vmID = (params?["id"] as? String) ?? "?" + guard vmID == "fresh-1" else { + return self.v2Response(id: id, ok: false, error: ["code": "wrong_machine", "message": "routed to \(vmID)"]) + } + return self.vmExecOKResponse(id: id, stdout: "fresh\n") + default: + return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "Unexpected method \(method)"]) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["HOME"] = isolatedHome.path + + let result = runProcess( + executablePath: cliPath, + arguments: ["vm", "run", "--", "echo", "fresh"], + environment: environment, + timeout: 30 + ) + + wait(for: [serverHandled], timeout: 30) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, "stdout=\(result.stdout) stderr=\(result.stderr)") + XCTAssertEqual(result.stdout, "fresh\n") + let commands = state.snapshot() + XCTAssertTrue(commands.contains { $0.contains(#""method":"vm.create""#) }) + XCTAssertTrue( + commands.contains { $0.contains(#""method":"vm.rename""#) && $0.contains("agent-pool") }, + "a provisioned machine must be labeled into the pool" + ) + } + + func testVMRunPrefersStickyBoundMachine() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("vm-run-sticky") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let isolatedHome = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-vm-run-home-\(UUID().uuidString.prefix(8))") + try FileManager.default.createDirectory( + at: isolatedHome.appendingPathComponent(".cmuxterm"), + withIntermediateDirectories: true + ) + defer { try? FileManager.default.removeItem(at: isolatedHome) } + + // Bind the current directory's work to pool-2 even though pool-1 is idle. + let workKey = Self.vmRunWorkKey(forDirectory: FileManager.default.currentDirectoryPath) + let bindings = [workKey: ["machine": "pool-2", "updatedAtUnix": Int(Date().timeIntervalSince1970)]] + let bindingsData = try JSONSerialization.data(withJSONObject: bindings) + try bindingsData.write(to: isolatedHome.appendingPathComponent(".cmuxterm/vm-run-bindings.json")) + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + if line.hasPrefix("auth ") { return "OK" } + guard let request = self.jsonObject(line), + let id = request["id"] as? String, + let method = request["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + switch method { + case "vm.list": + return self.v2Response(id: id, ok: true, result: [ + "vms": [ + ["id": "pool-1", "displayName": "agent-pool", "status": "running", "provider": "blaxel", "image": "blaxel/base-image:latest"], + ["id": "pool-2", "displayName": "agent-pool", "status": "standby", "provider": "blaxel", "image": "blaxel/base-image:latest"], + ], + ]) + case "vm.exec": + let params = request["params"] as? [String: Any] + let vmID = (params?["id"] as? String) ?? "?" + guard vmID == "pool-2" else { + return self.v2Response(id: id, ok: false, error: ["code": "wrong_machine", "message": "sticky binding ignored; routed to \(vmID)"]) + } + return self.vmExecOKResponse(id: id, stdout: "warm\n") + default: + return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "Unexpected method \(method)"]) + } + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + environment["HOME"] = isolatedHome.path + + let result = runProcess( + executablePath: cliPath, + arguments: ["vm", "run", "--", "echo", "warm"], + environment: environment, + timeout: 30 + ) + + wait(for: [serverHandled], timeout: 30) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, "stdout=\(result.stdout) stderr=\(result.stderr)") + XCTAssertEqual(result.stdout, "warm\n") + XCTAssertFalse( + state.snapshot().contains { $0.contains(#""method":"vm.stats""#) }, + "a sticky binding should route without load-scoring the pool" + ) + } + + func testVMWaitPollsStatusUntilReady() throws { + let cliPath = try bundledCLIPath() + let socketPath = makeSocketPath("vm-wait") + let listenerFD = try bindUnixSocket(at: socketPath) + let state = MockSocketServerState() + let pollCounter = VMTransferMockState() + + defer { + Darwin.close(listenerFD) + unlink(socketPath) + } + + let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in + if line.hasPrefix("auth ") { return "OK" } + guard let request = self.jsonObject(line), + let id = request["id"] as? String, + let method = request["method"] as? String else { + return self.malformedRequestResponse(raw: line) + } + guard method == "vm.status" else { + return self.v2Response(id: id, ok: false, error: ["code": "unexpected", "message": "Unexpected method \(method)"]) + } + let call = pollCounter.nextCount() + let status = call < 2 ? "creating" : "running" + return self.v2Response(id: id, ok: true, result: [ + "id": "brave-otter", + "provider": "blaxel", + "status": status, + ]) + } + + var environment = ProcessInfo.processInfo.environment + environment["CMUX_SOCKET_PATH"] = socketPath + environment["CMUX_CLI_SENTRY_DISABLED"] = "1" + + let result = runProcess( + executablePath: cliPath, + arguments: ["vm", "wait", "brave-otter", "--timeout", "30"], + environment: environment, + timeout: 30 + ) + + wait(for: [serverHandled], timeout: 30) + XCTAssertFalse(result.timedOut, result.stderr) + XCTAssertEqual(result.status, 0, "stdout=\(result.stdout) stderr=\(result.stderr)") + XCTAssertTrue(result.stdout.contains("ready"), result.stdout) + XCTAssertTrue( + state.snapshot().filter { $0.contains(#""method":"vm.status""#) }.count >= 2, + "wait must poll status more than once before ready" + ) + } +} diff --git a/docs/cli-contract.md b/docs/cli-contract.md index faffc41db1ac..16017649aaad 100644 --- a/docs/cli-contract.md +++ b/docs/cli-contract.md @@ -223,6 +223,10 @@ VM subcommands: | `vm ssh-info` | Print SSH connection info. | | `vm ssh-attach` | Internal attach helper. | | `vm exec` | Run a shell command inside a VM. | +| `vm run -- ` | Run a command without naming a machine: reuses an idle `agent-pool` machine, wakes a sleeper, or provisions a fresh one; `--sync` pushes the cwd first, `--pull ` fetches results, and the remote exit code passes through. | +| `vm push [remote]`, `vm upload` | Copy a local file or directory onto a VM over the exec channel (base64-chunked, SHA-256 verified; directories travel as tarballs). | +| `vm pull [local]`, `vm download` | Copy a file or directory from a VM to local disk over the exec channel. | +| `vm wait ` | Block until the VM reports a ready status; `--wake` also runs a trivial exec so a sleeping machine is awake, `--timeout ` bounds the wait. | Remotes subcommands: @@ -561,8 +565,8 @@ the expected text without connecting to a cmux socket. - `cmux capabilities --help` -> `Usage: cmux capabilities` - `cmux events --help` -> `Usage: cmux events [options]` - `cmux auth --help` -> `Usage: cmux auth ` -- `cmux vm --help` -> `Usage: cmux vm [args...]` -- `cmux cloud --help` -> `Usage: cmux cloud [args...]` +- `cmux vm --help` -> `Usage: cmux vm [args...]` +- `cmux cloud --help` -> `Usage: cmux cloud [args...]` - `cmux remotes --help` -> `Usage: cmux remotes [options]` - `cmux remote --help` -> `Usage: cmux remotes [options]` - `cmux rpc --help` -> `Usage: cmux rpc [json-params]` diff --git a/docs/internal/machine-router.md b/docs/internal/machine-router.md new file mode 100644 index 000000000000..e17eac5e8250 --- /dev/null +++ b/docs/internal/machine-router.md @@ -0,0 +1,49 @@ +# Machine router: invisible cloud machines for coding agents + +Status: v1 shipped in the CLI (`cmux vm run`); this doc records the design and the path to the control-plane version that pairs with cmux-coderouter. + +## Goal + +An agent (Claude Code, Codex, or any open-source-model harness) should be able to say *"run this in the cloud"* and never think about machines: no ids, no capacity, no setup. The router picks the computer, provisions when needed, keeps warm state where the work is, and meters usage — the same way cmux-coderouter makes model credentials invisible behind a `crk_` key. + +## v1 — CLI-side router (this repo, shipped) + +`cmux vm run [--sync] [--pull ] -- ` routes over the existing `vm.*` socket methods: + +1. **Sticky first.** A local binding store (`~/.cmuxterm/vm-run-bindings.json`) maps a work key — SHA-256 of the caller's directory — to the machine that last ran that work. A bound, ready pool machine wins outright: it holds the synced checkout, installed dependencies, and build caches. This mirrors coderouter's sticky `conversationKey → credential` assignment, which exists for the same reason (warm state is throughput). +2. **Then load-aware scoring.** Pool machines (label `agent-pool`) are tiered: awake and under 60% CPU (least-loaded first) → asleep (exec wakes them) → provision fresh → at the plan cap, share the least-loaded busy machine. Stats reads never wake a sleeping machine. +3. **Pool isolation.** The router only touches machines labeled `agent-pool` (which it creates and labels itself). A machine the user made and named by hand is never drafted into agent work. +4. **Deterministic contract.** `--machine ` pins, `--new` forces a fresh machine, the remote exit code passes through, `--json` returns `{machine, created, exit_code, stdout, stderr, ...}`. + +Supporting primitives shipped alongside: `vm push` / `vm pull` (chunked, digest-verified file transfer over exec — works on any provider with a shell, no SSH), and `vm wait` (readiness gate). + +## Why coderouter is the template + +cmux-coderouter (`manaflow/cmux-coderouter`) already solved this shape for model credentials: Worker edge → per-`team:family` `PoolCoordinator` Durable Object → upstreams, with the control plane owning durable state and billing. Model coverage there is exactly the set we care about — **Claude** (Anthropic OAuth/BYOK/managed via `/anthropic/*`), **Codex** (`/codex/*`, OAuth), and **open-source models** through OpenAI-compatible BYOK families (Groq for Llama/Kimi, z.ai for GLM, OpenRouter for anything, plus Gemini/xAI) — so agents on any of those model families can be given the same machine story. + +Patterns to carry over verbatim when the router graduates server-side: + +| coderouter pattern | machine-router analogue | +|---|---| +| Sticky `conversationKey` derived from headers → body → hash fallback, always non-empty | Work key from agent session id → repo+branch → cwd hash (v1 ships the cwd hash) | +| Tier ladder: subscription OAuth → BYOK → managed; never a cooling credential | Warm machine → sleeping machine → fresh provision → shared busy machine; never a quarantined one | +| Headroom + expiry-pressure scoring, deterministic final tie-break | CPU/RAM headroom + "reservation about to lapse" pressure, stable id tie-break (unit-testable without mocks) | +| Health windows from response headers + adaptive active probing; 429 → exponential cooldown, repeated 401 → quarantine | Normalize provider capacity errors/exec failures/disk-full into one health state; probe on a traffic-adaptive cadence; two strikes → reprovision, not retry-forever | +| In-request failover with an exclusion list and an explicit replay budget | Re-route a *fresh* command to the next machine transparently; never silently replay a half-streamed one | +| Control plane pushes full versioned `PoolConfig`; DO lazy-pulls on cold start; usage flushes back batched + deduped, response carries fresh balance | Same split: Postgres owns inventory/quotas/billing, a per-team `MachineCoordinator` owns live leases and health, machine-minutes flow back as deduped events debiting the Stack Auth credit item | +| Credential never reaches the caller; only `x-coderouter-credential: ` is echoed | The agent may learn the machine *class* it got, never raw provider addresses or credentials | + +## v2 — control-plane linkage (next) + +The concrete wiring, in dependency order: + +1. **`vm run` learns a session work key.** Accept `--work-key ` and default it from agent session env (`CMUX_WORKSPACE_ID`, Claude/Codex session ids) before the cwd hash, so parallel agents in one repo get their own lanes ("chunking" across machines falls out of distinct work keys). +2. **Move the binding store server-side.** `vm.route` socket method → `POST /api/vm/route` with `{workKey, requirements}` returning `{machineId, created}`; the web control plane owns bindings and the pool, so routing is consistent across the user's Macs and future headless callers. The CLI store becomes a cache. +3. **`MachineCoordinator` per team** (mirroring `PoolCoordinator` per `team:family`): live inventory, leases with TTL, health windows, scale-out decisions against plan entitlements (`maxActiveVms`), machine-minute metering back to billing. +4. **crk_ keys become machine-entitled.** A coderouter key's policy grows `machines: {maxConcurrent, sizes}`; the gateway (or cmux.com API accepting `crk_` auth on `/api/vm/route`) lets an agent that already talks to coderouter for Claude/Codex/OSS models get compute with the *same* credential — zero extra setup, one revocation point, one usage ledger. +5. **Open-endpoint gap.** coderouter's upstream bases are hardcoded; when per-team custom OpenAI-compatible endpoints land (vLLM on a routed machine is the natural first case), a machine provisioned by this router can *serve* an open-source model that coderouter then routes to — the two planes compose. + +## Non-goals + +- The router never deletes machines to make room; at the plan cap it degrades to sharing and tells the user. +- No scheduler-style bin-packing of arbitrary jobs; the unit is "an agent's working session", which is what stickiness optimizes. diff --git a/skills/cmux-cloud-vm/SKILL.md b/skills/cmux-cloud-vm/SKILL.md new file mode 100644 index 000000000000..c9d160aa5e6a --- /dev/null +++ b/skills/cmux-cloud-vm/SKILL.md @@ -0,0 +1,75 @@ +--- +name: cmux-cloud-vm +description: Drive cmux Cloud machines (persistent cloud VMs) from the CLI — `cmux vm run` routes commands to a machine automatically (no ids), plus create, exec, push/pull files, ports, checkpoints, forks, desktops. Use when an agent should run builds, tests, servers, or experiments on a cloud machine instead of the local Mac, or when the user says "cloud machine", "cloud VM", "run it in the cloud", or "cmux vm". +--- + +# cmux Cloud Machines + +Everything the Machines sidebar can do, from the CLI — plus agent-only primitives (`exec`, `push`, `pull`, `wait`). Requires the cmux app running and a signed-in account (`cmux auth status`, `cmux auth login`). + +- **Machine**: a persistent cloud VM (`cmux vm ls`). It sleeps when idle (free while asleep) and wakes on connect or exec; home survives sleep. +- **Box types**: Desktop (default image, has a screen reachable over noVNC) and Base (`--base`, shell-only). +- **Base**: one pinned persistent slot (`cmux vm base open`) that reuses the same VM every time. +- **Checkpoint / fork**: `snapshot` mints a restorable checkpoint; `fork` clones a machine for a parallel experiment. +- **Plan meter**: `cmux vm ls` prints "N of M machines". At the limit, creates fail with an upgrade action — never delete machines to make room without asking the user. + +## Fast start — let the router pick the machine + +You usually don't need a machine id at all. `cmux vm run` routes for you: it reuses the warm machine bound to your current directory, else an idle pool machine, wakes a sleeper, or provisions a fresh one — then passes the exit code through. + +```bash +cmux vm run -- uname -a # zero setup: routed, executed, done +cmux vm run --sync -- bun test # push cwd to work/ first, run there +cmux vm run --sync --pull work/app/dist -- sh -c 'cd work/app && bun run build' +``` + +Repeat runs from the same directory hit the same machine (sticky binding), so synced checkouts and installed dependencies stay warm. `--new` forces a fresh machine; `--machine ` pins one. + +## Named-machine primitives + +```bash +cmux vm ls --json # fleet + plan meter; reuse before creating +cmux vm new --base --detach --json # shell-only machine, no UI churn; prints the id +cmux vm wait --wake # block until ready and awake +cmux vm exec -- uname -a # run a command; exit code passes through +cmux vm push ./myrepo work/myrepo # copy a dir up (no SSH needed; .git etc. skipped) +cmux vm pull work/out.tgz # copy results back +cmux vm open 3000 --print # mint a private tokened URL for an HTTP port +cmux vm shell # show the human: terminal pane in their cmux +cmux vm desktop # show the human: the machine's screen (desktop boxes) +``` + +Every subcommand honors the global `--json` flag and exits non-zero on failure; `vm exec` and `vm run` pass the remote exit code through and keep stdout/stderr separated. This works for any agent — Claude Code, Codex, or open-source-model harnesses — it's all plain CLI. + +## Agent policy + +- **Prefer `vm run` over naming machines.** It reuses/wakes/provisions inside a dedicated pool (label `agent-pool`) and never drafts machines the user created by hand. Machines are the user's paid, limited resources. +- **Reuse before create** when you do name machines: `cmux vm ls` first; prefer an existing idle machine or `vm base open`. +- **Stay headless while working.** `vm new --detach`, then `exec`/`push`/`pull`. `vm shell`, `vm desktop`, and `vm open` (without `--print`) open panes in the user's app — use those to *show* results, not to do the work. +- **Checkpoint before risky operations** (`cmux vm snapshot `), and fork instead of experimenting on a machine the user relies on. +- **Only destroy what you created this session.** `vm rm` permanently deletes the machine and everything on it; confirm with the user otherwise. +- **Surface URLs and evidence proactively.** The user cannot see inside the machine. Print the `vm open --print` URL, pull artifacts, or open a shell/desktop pane when done, and `cmux notify` for long-running work. +- **Only share URLs minted by `cmux vm open`.** They are private and token-authenticated (and expire). Never reconstruct or guess raw provider preview URLs. + +## Common issues and fixes + +| Symptom | Fix | +|---------|-----| +| `vm exec` hangs or times out on a long command | Exec is capped (~30 s default). Background it: `cmux vm exec -- sh -c 'nohup make build > /tmp/build.log 2>&1 &'`, then poll `tail -n 20 /tmp/build.log`. | +| First command after idle is slow or flaky | The machine was asleep. `cmux vm wait --wake` first. | +| `vm new` opened a workspace in the user's app | Pass `--detach` (`-d`) in agent flows. | +| `vm ssh` errors on the default provider | SSH is provider-dependent. Use `vm exec` for commands and `vm shell` for an interactive pane. | +| Create fails with an active-limit error | The plan is at its machine cap. Report it and let the user upgrade or choose a machine to remove — don't pick for them. | +| Need to send file content into the machine | Don't inline big content in `exec` argv — `cmux vm push [remote]` (SHA-256 verified). | +| Pushed a repo but `.git` is missing | `push` skips `.git`, `node_modules`, `.venv`, `__pycache__`, `.DS_Store` by default. Pass `--no-default-excludes`, or ship history as a bundle (see workflows). | +| Push/pull refuses a large payload | Exec-chunked transfer caps at 256 MB. Clone/download inside the machine (`vm exec -- git clone …` / `curl -LO`). | +| Command works in `vm shell` but not `vm exec` | Exec is non-interactive and has no TTY/stdin. Use flags that skip prompts, or script the input. | + +## Deep-dive references + +| Reference | When to Use | +|-----------|-------------| +| [references/commands.md](references/commands.md) | Exhaustive `cmux vm` command list with examples | +| [references/agent-workflows.md](references/agent-workflows.md) | Recipes: cloud dev box from a local repo, cloud builds/tests, parallel forks, showing the human | +| [../cmux/SKILL.md](../cmux/SKILL.md) | Windows/workspaces/panes when presenting machine panes | +| [../cmux-workspace/SKILL.md](../cmux-workspace/SKILL.md) | Non-disruptive automation rules (focus, caller workspace) | diff --git a/skills/cmux-cloud-vm/agents/openai.yaml b/skills/cmux-cloud-vm/agents/openai.yaml new file mode 100644 index 000000000000..18c1a84fcfa1 --- /dev/null +++ b/skills/cmux-cloud-vm/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "cmux Cloud Machines" + short_description: "Create, reuse, and drive cmux Cloud VMs from the CLI: exec, push/pull, ports, checkpoints, forks, desktops." + default_prompt: "Use this skill to run work on cmux Cloud machines: list/reuse or create machines headlessly, wait for readiness, run commands with vm exec, transfer files with vm push/pull, mint private port URLs, checkpoint/fork for experiments, and present shells/desktops to the user when results are ready." diff --git a/skills/cmux-cloud-vm/references/agent-workflows.md b/skills/cmux-cloud-vm/references/agent-workflows.md new file mode 100644 index 000000000000..e6dc8af9a92d --- /dev/null +++ b/skills/cmux-cloud-vm/references/agent-workflows.md @@ -0,0 +1,85 @@ +# Agent workflows on cmux Cloud machines + +Recipes for doing the user's work *on* a machine while keeping the user in the loop. All of them assume `cmux auth status` reports signed-in. + +## 1. Cloud dev box from the local repo ("set it up like magic") + +The routed path — no machine id anywhere: + +```bash +cmux vm run --sync -- sh -c 'cd work/$(basename "$PWD") && bun install' +cmux vm run -- sh -c 'cd work/ && nohup bun run dev > /tmp/dev.log 2>&1 &' +cmux vm run -- sh -c 'sleep 2 && tail -n 5 /tmp/dev.log' # confirm it actually started +id=$(cmux vm run --json -- true | jq -r '.machine') # the machine the router bound +cmux vm open "$id" 3000 --print # tokened URL to give the user +``` + +Sticky binding means every `vm run` from this directory lands on the same machine, so the synced checkout and installed deps persist between commands. The explicit reuse-or-create spelling still works when you want full control: + +```bash +id=$(cmux vm ls --json | jq -r '.vms[0].id // empty') +[ -n "$id" ] || id=$(cmux vm new --base --detach --json | jq -r '.id') +cmux vm wait "$id" --wake +cmux vm push "$id" . work/app +cmux vm exec "$id" -- sh -c 'cd work/app && bun install' +``` + +Finish with `cmux notify --title "Cloud dev server up" --body ""` so the user can leave and return. + +## 2. Repo with history (private repos, no credentials on the machine) + +`push` skips `.git` by default. To work with real history without putting the user's tokens on the machine, ship a bundle: + +```bash +git bundle create /tmp/repo.bundle --all +cmux vm push /tmp/repo.bundle work/repo.bundle +cmux vm exec -- sh -c 'cd work && git clone repo.bundle app && cd app && git checkout main' +``` + +Public repos can just clone on the machine: `cmux vm exec -- git clone https://github.com/org/repo work/repo`. Never copy the user's `gh`/git credentials onto a machine unless they explicitly ask. + +## 3. Builds and tests in the cloud instead of the local Mac + +```bash +cmux vm exec -- sh -c 'cd work/app && nohup make test > /tmp/test.log 2>&1; echo done >> /tmp/test.log &' +# poll instead of holding a long exec open +cmux vm exec -- tail -n 30 /tmp/test.log +# bring artifacts home +cmux vm pull work/app/dist ./dist-from-cloud +``` + +Report the real outcome from the log — a finished poll is not a passed test. + +## 4. Parallel experiments with checkpoints and forks + +Never experiment on the user's machine state directly: + +```bash +cmux vm snapshot --name pre-experiment # restore point +fork_a=$(cmux vm fork --name try-approach-a --detach --json | jq -r '.id') +fork_b=$(cmux vm fork --name try-approach-b --detach --json | jq -r '.id') +# ...run a different approach on each fork with vm exec... +cmux vm rm "$fork_a" # remove only the forks you created +cmux vm rm "$fork_b" +``` + +Keep the original machine untouched; summarize what each fork showed before deleting anything. + +## 5. Showing the human + +The user cannot see exec output. When the work is ready: + +```bash +cmux vm shell # attach a terminal pane in their cmux window +cmux vm desktop # desktop boxes: stream the machine's screen +cmux vm open 3000 # open the app they should look at as a browser split +cmux vm handoff # print an attach block another human/agent can follow +``` + +Pair with `cmux notify` so they know why a pane appeared. Prefer `--print`/`--detach` variants until the moment you intend the user to look. + +## 6. Cleanup etiquette + +- Machines sleep on their own — idle machines cost nothing while asleep, so leaving a machine for the user to inspect is fine (say so in your handoff). +- Delete forks and scratch machines you created once their purpose is served. +- Never `vm rm` or `vm base reset` a machine you didn't create without explicit user confirmation — both discard data permanently (reset retains the old VM, but treat it as destructive). diff --git a/skills/cmux-cloud-vm/references/commands.md b/skills/cmux-cloud-vm/references/commands.md new file mode 100644 index 000000000000..2ea2004476b2 --- /dev/null +++ b/skills/cmux-cloud-vm/references/commands.md @@ -0,0 +1,92 @@ +# cmux vm command reference + +`cloud` is an alias for `vm` (`cmux cloud ls` == `cmux vm ls`). The global `--json` flag works on every subcommand and may appear before or after the subcommand. All of this requires the cmux app running and a signed-in account. + +## Context and discovery + +```bash +cmux auth status # signed in? +cmux vm ls # NAME / LABEL / STATE / PROVIDER / IMAGE + plan meter +cmux vm ls --json # {vms: [...], limits: {maxActiveVms, planId}} +cmux vm status # provider, status, image +cmux vm stats # CPU/mem/disk now; sleeping machines stay asleep +cmux vm tools # which tools are installed (git, gh, node, bun, python3, ...) +cmux vm ports # listening TCP ports inside the machine +cmux vm handoff # short attach block to paste to a human or another agent +``` + +## Lifecycle + +```bash +cmux vm new --detach # new Desktop machine (screen + shell), headless create +cmux vm new --base --detach # shell-only machine +cmux vm new --size 16g --detach # memory preset: 2g|4g|8g|16g|32g or raw MB +cmux vm wait [--timeout ] [--wake] # block until ready; --wake also wakes it +cmux vm rename