From d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 9 Sep 2026 20:04:50 -0700 Subject: [PATCH 01/18] cloud: bump the devbox agent pins to the current releases, add bubblewrap, and make the manifest prove it describes the promoted image The promoted devbox ladder (epoch 2026-09-07-r1) bakes Claude Code 2.1.252, Codex 0.151.0, opencode 1.18.25, pi 0.84.4 and agent-browser 0.35.2 while the registry is at 2.1.267 / 0.154.0 / 1.18.30 / 0.85.1 / 0.37.1, and codex warns on every launch that bubblewrap is missing. Machines never self-update by design, so a new release only reaches cmux Cloud through a rebake. - Dockerfile: pins bumped to the current releases, CMUX_IMAGE_EPOCH 2026-09-09-r1, bubblewrap in the devtools layer (the Freestyle bake installs the same list); codex uses the distro bwrap instead of its bundled copy. - `bun run devbox:pins:check [--write]`: compares the ARG pins with the npm registry and rewrites them; the pure rewrite refuses ranges, tags and packages the image does not bake. - Every manifest entry now records its epoch and a digest of the sources the bake took from the checkout (verbatim files + pins, per layer set). `devboxSourceDriftProblems` (devbox:manifest:check, the manifest test, and promote before it writes) fails when a default was baked at another epoch or from other sources, so main cannot describe a machine the promoted default is not. Rollback reverts the promotion commit whole. - verify-devbox-image.ts launches the real claude and codex TUIs as root and as ubuntu and requires the ready composer with no first-run gate text (polled readiness, bounded), and requires bwrap. The manifest test is red on this commit on purpose: the defaults are still the 2026-09-07-r1 ladder. The next commit promotes the new bake. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AxoApJ1xp8qCKmngR2Jgmx --- .github/workflows/cloud-vm-image-contract.yml | 6 + web/package.json | 1 + web/scripts/build-devbox-freestyle.ts | 7 +- web/scripts/check-devbox-agent-pins.ts | 71 +++++++++ web/scripts/devbox-image-common.ts | 143 +++++++++++++++++- web/scripts/promote-devbox-image.ts | 9 +- web/scripts/validate-devbox-ladder.ts | 16 +- web/scripts/verify-devbox-image.ts | 40 +++++ web/services/vms/images/devbox/Dockerfile | 26 ++-- .../vms/images/devbox/agent-config.sh | 6 +- .../vms/images/devbox/codex-managed.toml | 3 +- web/tests/vm-devbox-image.test.ts | 84 +++++++++- web/tests/vm-image-manifest.test.ts | 96 +++++++++++- 13 files changed, 485 insertions(+), 23 deletions(-) create mode 100644 web/scripts/check-devbox-agent-pins.ts diff --git a/.github/workflows/cloud-vm-image-contract.yml b/.github/workflows/cloud-vm-image-contract.yml index 7245537b14a..41d72c15c00 100644 --- a/.github/workflows/cloud-vm-image-contract.yml +++ b/.github/workflows/cloud-vm-image-contract.yml @@ -10,6 +10,9 @@ on: - web/scripts/derive-devbox-sizes.ts - web/scripts/promote-devbox-image.ts - web/scripts/validate-devbox-ladder.ts + - web/scripts/build-devbox-freestyle.ts + - web/scripts/verify-devbox-image.ts + - web/scripts/check-devbox-agent-pins.ts - web/services/vms/images/manifest.json - web/services/vms/images/sizes.ts - web/services/vms/images/devbox/** @@ -24,6 +27,9 @@ on: - web/scripts/derive-devbox-sizes.ts - web/scripts/promote-devbox-image.ts - web/scripts/validate-devbox-ladder.ts + - web/scripts/build-devbox-freestyle.ts + - web/scripts/verify-devbox-image.ts + - web/scripts/check-devbox-agent-pins.ts - web/services/vms/images/manifest.json - web/services/vms/images/sizes.ts - web/services/vms/images/devbox/** diff --git a/web/package.json b/web/package.json index 9a41a975624..5e4e8433e43 100644 --- a/web/package.json +++ b/web/package.json @@ -26,6 +26,7 @@ "devbox:verify:private-link": "bun scripts/verify-devbox-private-link.ts", "devbox:promote": "bun scripts/promote-devbox-image.ts", "devbox:manifest:check": "bun scripts/validate-devbox-ladder.ts", + "devbox:pins:check": "bun scripts/check-devbox-agent-pins.ts", "devbox:probe:busybox": "bun scripts/probe-busybox-cmux-tui.ts", "db:check": "bunx drizzle-kit check --config drizzle.config.ts", "db:down": "bash scripts/db-local.sh down", diff --git a/web/scripts/build-devbox-freestyle.ts b/web/scripts/build-devbox-freestyle.ts index 9e9732a7844..ae0eb0fd4c2 100644 --- a/web/scripts/build-devbox-freestyle.ts +++ b/web/scripts/build-devbox-freestyle.ts @@ -248,9 +248,12 @@ try { // daemon, the journal): see the identity contract in the header. await step("identity", devboxIdentityInstallCommand()); + // The Dockerfile's devtools list, bubblewrap included: codex's Linux sandbox + // prerequisite, so codex uses the distro's bwrap instead of warning on every + // launch that it is falling back to its bundled copy. await step( "apt-devtools", - "apt-get update -q && apt-get install -y --no-install-recommends git ripgrep build-essential curl ca-certificates unzip zip xz-utils zstd procps iproute2 openssh-client pkg-config jq fd-find fzf sqlite3 tmux less rsync file tree nano vim sudo util-linux && rm -rf /var/lib/apt/lists/* && ln -sf $(command -v fdfind) /usr/local/bin/fd && echo 'LANG=C.UTF-8' > /etc/default/locale && fd --version && jq --version && fzf --version && sqlite3 --version && tmux -V", + "apt-get update -q && apt-get install -y --no-install-recommends git ripgrep build-essential curl ca-certificates unzip zip xz-utils zstd procps iproute2 openssh-client pkg-config jq fd-find fzf sqlite3 tmux less rsync file tree nano vim sudo util-linux bubblewrap && rm -rf /var/lib/apt/lists/* && ln -sf $(command -v fdfind) /usr/local/bin/fd && echo 'LANG=C.UTF-8' > /etc/default/locale && fd --version && jq --version && fzf --version && sqlite3 --version && tmux -V && bwrap --version", ); await step( @@ -560,7 +563,7 @@ try { } } -const metadata = bakeMetadata(preflight, fileURLToPath(import.meta.url)); +const metadata = bakeMetadata(preflight, fileURLToPath(import.meta.url), withDesktop ? "desktop" : "base"); emitBakeResult({ provider: "freestyle", imageId: snapshotId, diff --git a/web/scripts/check-devbox-agent-pins.ts b/web/scripts/check-devbox-agent-pins.ts new file mode 100644 index 00000000000..f22e839aaee --- /dev/null +++ b/web/scripts/check-devbox-agent-pins.ts @@ -0,0 +1,71 @@ +#!/usr/bin/env bun +/** + * Compare the devbox Dockerfile's coding-agent pins (`ARG + * CMUX_IMAGE__VERSION`) with the npm registry's current release of each + * package, and optionally rewrite them. + * + * bun run devbox:pins:check # table; exit 1 when any pin is behind + * bun run devbox:pins:check --write # rewrite the ARG lines to the latest releases + * + * Pins are exact releases (never ranges or tags), the bake installs exactly + * them, and machines never self-update (DISABLE_AUTOUPDATER, + * check_for_update_on_startup = false), so the only way a new Claude Code or + * Codex reaches cmux Cloud is: bump here, bump CMUX_IMAGE_EPOCH, then + * `bun run devbox:promote -- freestyle` for both ladders and merge the + * manifest diff. `--write` rewrites the pins only; the epoch bump and the + * promotion stay explicit steps, printed at the end. + */ +import { writeFileSync } from "node:fs"; +import { + agentPinDrift, + devboxAgentPins, + devboxDockerfilePath, + devboxImageEpoch, + hasFlag, + readDevboxDockerfile, + rewriteDevboxAgentPins, + type AgentPinDrift, +} from "./devbox-image-common"; + +const REGISTRY = process.env.CMUX_NPM_REGISTRY?.replace(/\/+$/, "") || "https://registry.npmjs.org"; + +/** The registry's `latest` dist-tag for one package (`//latest` is the small per-version document). */ +async function latestRelease(pkg: string): Promise { + const url = `${REGISTRY}/${pkg}/latest`; + const response = await fetch(url, { headers: { accept: "application/json" } }); + if (!response.ok) throw new Error(`${url} -> ${response.status}`); + const body = (await response.json()) as { version?: unknown }; + if (typeof body.version !== "string" || !/^\d+\.\d+\.\d+$/.test(body.version)) { + throw new Error(`${url}: no exact x.y.z version in the response (${JSON.stringify(body.version)})`); + } + return body.version; +} + +function renderTable(rows: readonly AgentPinDrift[]): string { + const width = Math.max(...rows.map((row) => row.pkg.length)); + return rows + .map((row) => `${row.pkg.padEnd(width)} pinned ${row.pinned.padEnd(9)} latest ${row.latest.padEnd(9)} ${row.behind ? "BEHIND" : "current"}`) + .join("\n"); +} + +const dockerfile = readDevboxDockerfile(); +const pins = devboxAgentPins(dockerfile); +const latest = Object.fromEntries(await Promise.all(pins.map(async (pin) => [pin.pkg, await latestRelease(pin.pkg)] as const))); +const rows = agentPinDrift(pins, latest); +console.log(`devbox agent pins (epoch ${devboxImageEpoch(dockerfile)}, registry ${REGISTRY}):\n${renderTable(rows)}`); +const behind = rows.filter((row) => row.behind); +if (behind.length === 0) { + console.log("every pin is the registry's current release"); + process.exit(0); +} +if (!hasFlag("--write")) { + console.log(`${behind.length} pin(s) behind; rerun with --write to rewrite them`); + process.exit(1); +} +writeFileSync(devboxDockerfilePath, rewriteDevboxAgentPins(dockerfile, Object.fromEntries(behind.map((row) => [row.pkg, row.latest])))); +console.log( + `rewrote ${behind.length} pin(s) in ${devboxDockerfilePath}\n` + + "next: bump CMUX_IMAGE_EPOCH in the same file, then promote both ladders:\n" + + " FREESTYLE_API_KEY=... bun run devbox:promote -- freestyle --kinds desktop --slug cmux-devbox- --pointer-slug cmux-devbox-\n" + + " FREESTYLE_API_KEY=... bun run devbox:promote -- freestyle --kinds base --no-desktop --slug cmux-devbox--base --pointer-slug cmux-devbox--base", +); diff --git a/web/scripts/devbox-image-common.ts b/web/scripts/devbox-image-common.ts index a1227ed9527..63962821f8c 100644 --- a/web/scripts/devbox-image-common.ts +++ b/web/scripts/devbox-image-common.ts @@ -258,7 +258,7 @@ export function devboxGhosttyDebSha256(dockerfile = readDevboxDockerfile()): str return sha; } -const AGENT_PIN_ARGS: readonly { arg: string; pkg: string; binary: string }[] = [ +export const AGENT_PIN_ARGS: readonly { arg: string; pkg: string; binary: string }[] = [ { arg: "CMUX_IMAGE_CLAUDE_CODE_VERSION", pkg: "@anthropic-ai/claude-code", binary: "claude" }, { arg: "CMUX_IMAGE_CODEX_VERSION", pkg: "@openai/codex", binary: "codex" }, { arg: "CMUX_IMAGE_OPENCODE_VERSION", pkg: "opencode-ai", binary: "opencode" }, @@ -281,6 +281,47 @@ export function readDevboxDockerfile(): string { return readFileSync(devboxDockerfilePath, "utf8"); } +/** An exact npm release: `x.y.z`, never a range, a tag, or a prerelease. */ +export const EXACT_AGENT_PIN = /^\d+\.\d+\.\d+$/; + +/** + * Rewrites the Dockerfile's `ARG CMUX_IMAGE__VERSION=` lines to + * `versions` (keyed by npm package), leaving every other byte alone. The one + * sanctioned way to bump a pin (`bun run devbox:pins:check --write`): a pin is + * an exact release, a package the Dockerfile does not bake is a mistake, and + * a missing ARG line means the recipe no longer matches this table. + */ +export function rewriteDevboxAgentPins(dockerfile: string, versions: Record): string { + let next = dockerfile; + for (const [pkg, version] of Object.entries(versions)) { + const pin = AGENT_PIN_ARGS.find((candidate) => candidate.pkg === pkg); + if (!pin) throw new Error(`${pkg} is not a devbox agent pin (${AGENT_PIN_ARGS.map((row) => row.pkg).join(", ")})`); + if (!EXACT_AGENT_PIN.test(version)) throw new Error(`${pkg}: ${version} is not an exact x.y.z release`); + const line = new RegExp(`^ARG ${pin.arg}=\\S+$`, "m"); + if (!line.test(next)) throw new Error(`devbox Dockerfile is missing ARG ${pin.arg}`); + next = next.replace(line, `ARG ${pin.arg}=${version}`); + } + return next; +} + +export type AgentPinDrift = { + readonly pkg: string; + readonly binary: string; + readonly pinned: string; + readonly latest: string; + /** The registry's latest is not the pin (a newer release, or a pin ahead of a yanked latest). */ + readonly behind: boolean; +}; + +/** Pins next to the registry's current release; `latest` is keyed by npm package. */ +export function agentPinDrift(pins: readonly AgentPin[], latest: Readonly>): AgentPinDrift[] { + return pins.map((pin) => { + const current = latest[pin.pkg]; + if (!current) throw new Error(`no registry version for ${pin.pkg}`); + return { pkg: pin.pkg, binary: pin.binary, pinned: pin.version, latest: current, behind: current !== pin.version }; + }); +} + /** The cua computer-use driver pin, from the Dockerfile (never a second copy). */ export function devboxCuaDriverVersion(dockerfile = readDevboxDockerfile()): string { const version = /CUA_DRIVER_RS_VERSION=(\S+)/.exec(dockerfile)?.[1]; @@ -292,6 +333,51 @@ export function devboxImageEpoch(dockerfile = readDevboxDockerfile()): string { return /CMUX_IMAGE_EPOCH=([^\s"]+)/.exec(dockerfile)?.[1] ?? "none"; } +/** + * Everything the Freestyle bake takes from this checkout for an image with + * `layers` (the shell layer for `base`, plus the desktop layer for + * `desktop`): the files shipped verbatim (sha256 each, the desktop files by + * their DEVBOX_DESKTOP_INSTALLS path), the pins the Dockerfile ARGs carry + * (agents, cua-driver, the Ghostty .deb, the desktop apt list) and the + * epoch. Dockerfile prose is deliberately not part of it: a comment cannot + * change a machine. `devboxSourceDigest` is its sha256, recorded on every + * manifest entry at bake time so `devboxSourceDriftProblems` can tell when + * main describes a machine the promoted default no longer is. + */ +export function devboxSourceManifest(layers: DevboxImageKind, dockerfile = readDevboxDockerfile()): Record { + const files = Object.fromEntries( + DEVBOX_TEMPLATE_FILES.filter((name) => name !== "Dockerfile").map((name) => [name, sha256File(path.join(devboxDir, name))]), + ); + const shell = { + schema: 1, + layers, + epoch: devboxImageEpoch(dockerfile), + agentPins: Object.fromEntries(devboxAgentPins(dockerfile).map((pin) => [pin.pkg, pin.version])), + cuaDriver: devboxCuaDriverVersion(dockerfile), + ghosttyVersion: devboxGhosttyVersion(dockerfile), + files, + }; + if (layers === "base") return shell; + return { + ...shell, + ghosttyDeb: { url: devboxGhosttyDebUrl(dockerfile), sha256: devboxGhosttyDebSha256(dockerfile) }, + desktopPackages: devboxDesktopPackages(dockerfile), + desktopFiles: Object.fromEntries( + DEVBOX_DESKTOP_INSTALLS.map((install) => [install.source, sha256File(path.join(devboxDir, install.source))]), + ), + }; +} + +export function devboxSourceDigest(layers: DevboxImageKind, dockerfile = readDevboxDockerfile()): string { + return createHash("sha256").update(JSON.stringify(devboxSourceManifest(layers, dockerfile))).digest("hex"); +} + +/** Which layers an image carries and the digest of the sources they were baked from. */ +export type DevboxSourceRecord = { + readonly layers: DevboxImageKind; + readonly digest: string; +}; + export function devboxTemplateFile(name: string): string { return readFileSync(path.join(devboxDir, name), "utf8"); } @@ -516,11 +602,13 @@ export type DevboxBakeMetadata = { readonly repoCommit: string; readonly builderScriptVersion: string; readonly agentToolResolvedVersions: Record; + readonly devboxSource: DevboxSourceRecord; }; export function bakeMetadata( preflight: { sha: string; epoch: string }, builderScriptPath: string, + layers: DevboxImageKind, ): DevboxBakeMetadata { return { builtAt: new Date().toISOString(), @@ -530,6 +618,7 @@ export function bakeMetadata( agentToolResolvedVersions: Object.fromEntries( devboxAgentPins().map((pin) => [pin.pkg, pin.version]), ), + devboxSource: { layers, digest: devboxSourceDigest(layers) }, }; } @@ -561,6 +650,10 @@ export type DevboxManifestEntry = { cmuxTuiSha256?: string; /** The cmux commit whose devbox definition produced this image. */ repoCommit?: string; + /** The Dockerfile's CMUX_IMAGE_EPOCH at bake time; older entries carry it in `notes` only (see manifestEntryEpoch). */ + epoch?: string; + /** The layers the image carries and the digest of the sources they were baked from (devboxSourceDigest). Absent on older entries. */ + devboxSource?: DevboxSourceRecord; builtAt: string; builderScriptVersion: string; agentToolResolvedVersions: Record; @@ -587,6 +680,8 @@ export function manifestEntrySkeleton( // artifacts manifest; no cmuxd-remote build is baked. cmuxdRemoteCommit: "none-cmux-tui", repoCommit: metadata.repoCommit, + epoch: metadata.epoch, + devboxSource: metadata.devboxSource, builtAt: metadata.builtAt, builderScriptVersion: metadata.builderScriptVersion, agentToolResolvedVersions: metadata.agentToolResolvedVersions, @@ -862,3 +957,49 @@ export function devboxImageLadderProblems( } return problems; } + + +/** The epoch an entry was baked at: the field, or the `cmux devbox epoch ` prefix every bake writes into `notes`. */ +export function manifestEntryEpoch(entry: Pick): string | undefined { + return entry.epoch ?? /cmux devbox epoch (\S+)/.exec(entry.notes ?? "")?.[1]; +} + +/** + * The invariant that makes the checked-in manifest describe the machine + * users get: every default of `provider` was baked at the Dockerfile's + * current CMUX_IMAGE_EPOCH (a bumped epoch without a promotion, or a + * rollback to an older ladder without reverting the sources, fails), and an + * entry that recorded its source digest was baked from exactly the files and + * pins in this checkout (a template or pin change without a re-promotion + * fails). Rollback therefore reverts the promotion commit as a whole, sources + * included, never the manifest flags alone. Entries without a digest predate + * the record and are held to the epoch only. + */ +export function devboxSourceDriftProblems( + manifest: DevboxImageManifest, + provider: DevboxProvider = "freestyle", + dockerfile = readDevboxDockerfile(), +): string[] { + const problems: string[] = []; + const epoch = devboxImageEpoch(dockerfile); + const digests = new Map(); + for (const entry of manifest.images) { + if (entry.provider !== provider || !entry.defaultForKind) continue; + const bakedEpoch = manifestEntryEpoch(entry); + if (bakedEpoch !== epoch) { + problems.push(`${entry.version}: baked at devbox epoch ${bakedEpoch ?? "(unknown)"}, the Dockerfile is at ${epoch}; promote a new bake or revert the sources with the manifest`); + } + const source = entry.devboxSource; + if (!source) continue; + if (source.layers !== "desktop" && source.layers !== "base") { + problems.push(`${entry.version}: devboxSource.layers ${String(source.layers)} is not desktop|base`); + continue; + } + const current = digests.get(source.layers) ?? devboxSourceDigest(source.layers, dockerfile); + digests.set(source.layers, current); + if (source.digest !== current) { + problems.push(`${entry.version}: baked from devbox sources ${source.digest.slice(0, 12)}…, this checkout's ${source.layers} sources are ${current.slice(0, 12)}…; promote a new bake or revert the sources with the manifest`); + } + } + return problems; +} diff --git a/web/scripts/promote-devbox-image.ts b/web/scripts/promote-devbox-image.ts index 3a6f8fc020b..fe5f123d611 100644 --- a/web/scripts/promote-devbox-image.ts +++ b/web/scripts/promote-devbox-image.ts @@ -56,6 +56,7 @@ import { bakeMetadata, bakePreflight, defaultBakeTag, + devboxSourceDriftProblems, hasFlag, imageManifestPath, imageManifestProblems, @@ -126,7 +127,7 @@ if (bakeResultPath) { console.log(`adopting bake result ${bakeResultPath}: ${imageId}`); } else if (existingImage) { const preflight = bakePreflight({ desktop: withDesktop }); - const metadata = bakeMetadata(preflight, path.join(scriptsDir, `build-devbox-${provider}.ts`)); + const metadata = bakeMetadata(preflight, path.join(scriptsDir, `build-devbox-${provider}.ts`), withDesktop ? "desktop" : "base"); imageId = existingImage; entry = manifestEntrySkeleton( provider, @@ -209,7 +210,11 @@ const manifest = readImageManifest(); const next = skipVerify ? { ...manifest, images: [...manifest.images, { ...entry, kind: kinds[0], notes: [entry.notes, validationNotes].filter(Boolean).join(" ") }] } : promoteImageManifestEntry(manifest, entry, { kinds, sizes, validationNotes }); -const problems = imageManifestProblems(next); +// The manifest must keep describing the machine users get: the new defaults +// carry this checkout's epoch and source digest, so a stale bake (an --image +// baked before a Dockerfile change, an epoch bumped after the bake) is refused +// here rather than caught by CI after the PR is open. +const problems = [...imageManifestProblems(next), ...(skipVerify ? [] : devboxSourceDriftProblems(next))]; if (problems.length > 0) { throw new Error(`refusing to write an inconsistent manifest:\n ${problems.join("\n ")}`); } diff --git a/web/scripts/validate-devbox-ladder.ts b/web/scripts/validate-devbox-ladder.ts index 0876933eedd..ec5b51694b3 100644 --- a/web/scripts/validate-devbox-ladder.ts +++ b/web/scripts/validate-devbox-ladder.ts @@ -1,17 +1,27 @@ #!/usr/bin/env bun -/** Validate the checked-in Freestyle image manifest and its complete ladder. */ +/** + * Validate the checked-in Freestyle image manifest: its invariants, its + * complete ladder, and that every default was baked from this checkout's + * devbox sources (epoch and source digest), so the manifest keeps describing + * the machine users get. + */ import { devboxImageLadderProblems, + devboxSourceDriftProblems, imageManifestProblems, readImageManifest, } from "./devbox-image-common"; const manifest = readImageManifest(); -const problems = [...imageManifestProblems(manifest), ...devboxImageLadderProblems(manifest)]; +const problems = [ + ...imageManifestProblems(manifest), + ...devboxImageLadderProblems(manifest), + ...devboxSourceDriftProblems(manifest), +]; if (problems.length > 0) { console.error(`devbox image manifest is invalid:\n ${problems.join("\n ")}`); process.exit(1); } const defaults = manifest.images.filter((entry) => entry.provider === "freestyle" && entry.defaultForKind); -console.log(`devbox image manifest ok: ${defaults.length} validated Freestyle defaults across base and desktop ladders`); +console.log(`devbox image manifest ok: ${defaults.length} validated Freestyle defaults across base and desktop ladders, all baked from this checkout's devbox sources`); diff --git a/web/scripts/verify-devbox-image.ts b/web/scripts/verify-devbox-image.ts index 73bd2c2254b..636aebd8aa6 100644 --- a/web/scripts/verify-devbox-image.ts +++ b/web/scripts/verify-devbox-image.ts @@ -82,6 +82,9 @@ const CHECKS: readonly string[] = [ "grep -q AGENT_BROWSER_EXECUTABLE_PATH /etc/profile.d/cmux-media.sh && echo media-profile-ok", "cua-driver --version", "ffmpeg -version | head -1 && command -v Xvfb && command -v xdpyinfo && command -v xdotool", + // codex's Linux sandbox prerequisite: without the distro bwrap, codex warns + // on every launch that it is falling back to its bundled copy. + "bwrap --version && echo bubblewrap-ok", // Baked files are byte-identical to this checkout. ...FILE_PIN_CHECKS, // Devshell: ble.sh installed, bashrc chained, tmux pinned to bash, seed @@ -189,6 +192,42 @@ const desktopChecks = (): readonly string[] => [ ...desktopFilePinChecks(), ]; +/** + * The first interactive launch of a coding agent reaches its prompt: every + * first-run gate the image seeds (claude: onboarding, folder trust, the + * bypass-permissions confirmation, the custom-API-key consent, the root gate; + * codex: folder trust, the startup update picker, the bubblewrap warning) is + * proven closed by launching the real TUI in a tmux pty as `user` and waiting + * for `marker`, the text only the ready composer shows. The pane must then + * carry none of the `forbidden` gate texts. Readiness is the marker itself, + * polled, never a fixed delay; the wait is bounded at 90 s. Runs in a login + * shell so the agent-config exports (CLAUDE_CODE_SANDBOXED, IS_SANDBOX, + * DISABLE_AUTOUPDATER, the codex() trust wrapper) apply, exactly as a pane + * or SSH login gets them; the launch itself is the seeded history command. + */ +const agentLaunchCheck = ( + user: string, + home: string, + label: string, + command: string, + marker: string, + forbidden: string, +): string => + `sudo -n -u ${user} env -i HOME=${home} USER=${user} TERM=xterm-256color PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash -lc 'cd "$HOME" && tmux -L ${label} new-session -d -s g -x 140 -y 40 "${command}" && for i in $(seq 1 90); do pane="$(tmux -L ${label} capture-pane -pt g)"; printf "%s\\n" "$pane" | grep -q "${marker}" && break; sleep 1; done; tmux -L ${label} kill-server 2>/dev/null; printf "%s\\n" "$pane" | grep -q "${marker}" || { printf "%s\\n" "$pane"; echo "no ${marker} within 90 s"; exit 1; }; printf "%s\\n" "$pane" | grep -Eiq "${forbidden}" && { printf "%s\\n" "$pane"; echo "first-run gate still up"; exit 1; }; echo ${label}-ok'`; +const CLAUDE_LAUNCH_MARKER = "bypass permissions on"; +const CLAUDE_GATE_TEXTS = "Do you trust|Detected a custom API key|text style that looks best|Yes, I accept|cannot be used with root|Select login method"; +const CODEX_LAUNCH_MARKER = "Ask Codex to do anything"; +const CODEX_GATE_TEXTS = "Do you trust|new version|bubblewrap|sandbox prerequisites|Sign in with ChatGPT"; +const AGENT_LAUNCH_CHECKS: readonly string[] = [ + agentLaunchCheck("root", "/root", "claude-root-launch", "claude --dangerously-skip-permissions", CLAUDE_LAUNCH_MARKER, CLAUDE_GATE_TEXTS), + agentLaunchCheck(DEVBOX_DESKTOP_USER, DEVBOX_DESKTOP_HOME, "claude-ubuntu-launch", "claude --dangerously-skip-permissions", CLAUDE_LAUNCH_MARKER, CLAUDE_GATE_TEXTS), + agentLaunchCheck("root", "/root", "codex-root-launch", "codex", CODEX_LAUNCH_MARKER, CODEX_GATE_TEXTS), + agentLaunchCheck(DEVBOX_DESKTOP_USER, DEVBOX_DESKTOP_HOME, "codex-ubuntu-launch", "codex", CODEX_LAUNCH_MARKER, CODEX_GATE_TEXTS), + // Nothing a launch wrote in the work user's home may be root-owned (the + // root probes ran with HOME=/root, never the work user's home). + `[ "$(find ${DEVBOX_DESKTOP_HOME} -not -user ${DEVBOX_DESKTOP_USER} | wc -l)" = 0 ] && echo home-still-owned-by-${DEVBOX_DESKTOP_USER}`, +]; + // Freestyle: the work user is the base's `ubuntu` (uid 1000, passwordless // sudo, the API's default exec user and the SSH default), the toolchain is // the base's (Node under nvm symlinked into /usr/local/bin, Bun, Python, uv, @@ -401,6 +440,7 @@ if (provider === "freestyle") { ...CHECKS, ...DAEMON_CHECKS, ...FREESTYLE_BASE_CHECKS, + ...AGENT_LAUNCH_CHECKS, ...IDENTITY_CHECKS, ...(desktop ? desktopChecks() diff --git a/web/services/vms/images/devbox/Dockerfile b/web/services/vms/images/devbox/Dockerfile index 389d3699250..71d803f47f4 100644 --- a/web/services/vms/images/devbox/Dockerfile +++ b/web/services/vms/images/devbox/Dockerfile @@ -32,14 +32,17 @@ FROM ubuntu:24.04 # Cache-buster: bump the date to force remote builders past stale layer # caches (a remote builder served stale layers for chatmux on 2026-08-21 and # 2026-08-25). Also the image version /etc/cmux/image-stamp carries. -ENV CMUX_IMAGE_EPOCH=2026-09-07-r1 +ENV CMUX_IMAGE_EPOCH=2026-09-09-r1 ENV LANG=C.UTF-8 \ DEBIAN_FRONTEND=noninteractive # Devtools (chatmux devbox apt list) plus vim, sudo for non-root provider -# exec users, procps for the driver's daemon process discovery (pgrep), and -# iproute2 for the desktop's port probes (ss). +# exec users, procps for the driver's daemon process discovery (pgrep), +# iproute2 for the desktop's port probes (ss), and bubblewrap: codex's Linux +# sandbox prerequisite (developers.openai.com/codex/concepts/sandboxing), so +# codex uses the distro's bwrap instead of warning on every launch that it is +# falling back to its bundled copy. RUN apt-get update && apt-get install -y --no-install-recommends \ git \ ripgrep \ @@ -67,10 +70,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ vim \ sudo \ util-linux \ + bubblewrap \ && rm -rf /var/lib/apt/lists/* \ && ln -s "$(command -v fdfind)" /usr/local/bin/fd \ && echo 'LANG=C.UTF-8' > /etc/default/locale \ - && fd --version && jq --version && fzf --version && sqlite3 --version && tmux -V && ss -V + && fd --version && jq --version && fzf --version && sqlite3 --version && tmux -V && ss -V && bwrap --version # GitHub CLI from the official apt repo (not in the Ubuntu archive). RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg \ @@ -194,12 +198,14 @@ RUN chmod 0755 /usr/local/bin/start-vnc.sh /usr/local/bin/cmux-desktop-boot \ # Coding agents, pinned at bake time (bump with the image epoch; keep in # lockstep with the chatmux devbox). Installed on the mise node so they -# ride the same toolchain users get. -ARG CMUX_IMAGE_CLAUDE_CODE_VERSION=2.1.252 -ARG CMUX_IMAGE_CODEX_VERSION=0.151.0 -ARG CMUX_IMAGE_OPENCODE_VERSION=1.18.25 -ARG CMUX_IMAGE_PI_VERSION=0.84.4 -ARG CMUX_IMAGE_AGENT_BROWSER_VERSION=0.35.2 +# ride the same toolchain users get. `bun run devbox:pins:check` (web/) +# compares these with the npm registry and `--write` rewrites them; a pin is +# always an exact release, never a range or a tag. +ARG CMUX_IMAGE_CLAUDE_CODE_VERSION=2.1.267 +ARG CMUX_IMAGE_CODEX_VERSION=0.154.0 +ARG CMUX_IMAGE_OPENCODE_VERSION=1.18.30 +ARG CMUX_IMAGE_PI_VERSION=0.85.1 +ARG CMUX_IMAGE_AGENT_BROWSER_VERSION=0.37.1 RUN npm install -g --foreground-scripts \ "@anthropic-ai/claude-code@${CMUX_IMAGE_CLAUDE_CODE_VERSION}" \ "@openai/codex@${CMUX_IMAGE_CODEX_VERSION}" \ diff --git a/web/services/vms/images/devbox/agent-config.sh b/web/services/vms/images/devbox/agent-config.sh index 2801671d934..f460b3dd4bd 100644 --- a/web/services/vms/images/devbox/agent-config.sh +++ b/web/services/vms/images/devbox/agent-config.sh @@ -117,7 +117,8 @@ cmux_write_agent_configs() { # skipDangerousModePermissionPrompt, rides # /etc/claude-code/managed-settings.json (written at image build). # A fourth gate is the custom-API-key consent ("Detected a custom API key - # in your environment ... use this API key?", seen live on 2.1.252): claude + # in your environment ... use this API key?", seen live on 2.1.252 and + # still gated the same way on 2.1.267): claude # records answers under customApiKeyResponses as the key's LAST 20 # characters, so the placeholder key from the env is pre-approved the same # way. Derived from the env so a placeholder change cannot desync it. @@ -195,7 +196,8 @@ export IS_SANDBOX=1 # on the first interactive launch (2.1.252 -> latest within 20 s, verified # live on the trust3 bake), reinstalling the package under nvm. That defeats # the image pin and leaves `claude` briefly unresolvable while npm relinks -# the bin. The binary is image-baked; new versions ship by rebake. +# the bin. The binary is image-baked; new versions ship by rebake +# (`bun run devbox:pins:check --write` in web/, then promote). export DISABLE_AUTOUPDATER=1 # codex folder-trust gate: codex has no sandbox env short-circuit and its diff --git a/web/services/vms/images/devbox/codex-managed.toml b/web/services/vms/images/devbox/codex-managed.toml index e65000f853f..64ce6cfead0 100644 --- a/web/services/vms/images/devbox/codex-managed.toml +++ b/web/services/vms/images/devbox/codex-managed.toml @@ -13,7 +13,8 @@ # # check_for_update_on_startup: the binary is image-baked, so the startup # update prompt (a blocking picker before the composer, seen live on 0.151.0) -# can only advertise versions the image cannot install. +# can only advertise versions the image cannot install. New codex releases +# ship by rebake (`bun run devbox:pins:check --write` in web/, then promote). check_for_update_on_startup = false [projects."/root"] diff --git a/web/tests/vm-devbox-image.test.ts b/web/tests/vm-devbox-image.test.ts index 0a431575484..47f21e48b86 100644 --- a/web/tests/vm-devbox-image.test.ts +++ b/web/tests/vm-devbox-image.test.ts @@ -10,7 +10,18 @@ import { CMUX_TUI_SESSION, cmuxTuiDaemonCommand, } from "../services/vms/drivers/cmuxTuiDaemon"; -import { DEVBOX_TEMPLATE_FILES, devboxAgentPins, devboxCuaDriverVersion, devboxGhosttyVersion, devboxParkDaemonCommand } from "../scripts/devbox-image-common"; +import { + AGENT_PIN_ARGS, + DEVBOX_TEMPLATE_FILES, + agentPinDrift, + devboxAgentPins, + devboxCuaDriverVersion, + devboxGhosttyVersion, + devboxParkDaemonCommand, + devboxSourceDigest, + devboxSourceManifest, + rewriteDevboxAgentPins, +} from "../scripts/devbox-image-common"; // Contract tests for the shared cmux Cloud devbox image template // (services/vms/images/devbox), consumed by build-devbox-freestyle.ts, @@ -368,6 +379,77 @@ describe("devbox image template", () => { expect(readScript("build-devbox-freestyle.ts")).toContain("devboxCuaDriverVersion()"); }); + test("agent pins are bumped through the rewrite helper, exactly and only for baked packages", () => { + // `bun run devbox:pins:check --write` is the one sanctioned way to bump a + // pin: it rewrites the ARG line and nothing else, refuses ranges, tags and + // packages the image does not bake, and fails on a Dockerfile whose ARG + // table no longer matches. + const pins = devboxAgentPins(dockerfile); + const bumped = Object.fromEntries(pins.map((pin) => [pin.pkg, `${pin.version}9`])); + const rewritten = rewriteDevboxAgentPins(dockerfile, bumped); + expect(devboxAgentPins(rewritten).map((pin) => [pin.pkg, pin.version])).toEqual(Object.entries(bumped)); + // Every other byte survives: revert the pins and the file is byte-identical. + expect(rewriteDevboxAgentPins(rewritten, Object.fromEntries(pins.map((pin) => [pin.pkg, pin.version])))).toBe(dockerfile); + expect(rewriteDevboxAgentPins(dockerfile, {})).toBe(dockerfile); + for (const bad of ["^2.1.0", "latest", "2.1", "2.1.0-beta.1"]) { + expect(() => rewriteDevboxAgentPins(dockerfile, { "@openai/codex": bad })).toThrow(/not an exact x\.y\.z release/); + } + expect(() => rewriteDevboxAgentPins(dockerfile, { "left-pad": "1.0.0" })).toThrow(/not a devbox agent pin/); + expect(() => rewriteDevboxAgentPins("FROM ubuntu:24.04\n", { "@openai/codex": "1.0.0" })).toThrow(/missing ARG CMUX_IMAGE_CODEX_VERSION/); + // The drift report keys by package and flags any pin that is not the registry's latest. + const latest = Object.fromEntries(pins.map((pin) => [pin.pkg, pin.version])); + expect(agentPinDrift(pins, latest).every((row) => !row.behind)).toBe(true); + const codex = pins.find((pin) => pin.pkg === "@openai/codex")!; + const drift = agentPinDrift(pins, { ...latest, "@openai/codex": `${codex.version}9` }); + expect(drift.filter((row) => row.behind).map((row) => row.pkg)).toEqual(["@openai/codex"]); + expect(() => agentPinDrift(pins, {})).toThrow(/no registry version/); + expect(AGENT_PIN_ARGS.map((row) => row.binary)).toEqual(["claude", "codex", "opencode", "pi", "agent-browser"]); + }); + + test("the source digest covers what the Freestyle bake takes from this checkout, per layer set", () => { + const base = devboxSourceManifest("base", dockerfile); + const desktop = devboxSourceManifest("desktop", dockerfile); + // Pins, epoch and the verbatim files: a pin bump, an epoch bump, or a + // template edit each changes the digest; Dockerfile prose does not. + expect(base).toMatchObject({ schema: 1, layers: "base", agentPins: Object.fromEntries(devboxAgentPins(dockerfile).map((pin) => [pin.pkg, pin.version])) }); + expect(Object.keys(base.files as Record).sort()).toEqual([...DEVBOX_TEMPLATE_FILES].filter((name) => name !== "Dockerfile").sort()); + expect(base).not.toHaveProperty("desktopFiles"); + expect(desktop).toHaveProperty("desktopFiles"); + expect(desktop).toHaveProperty("desktopPackages"); + expect(devboxSourceDigest("base", dockerfile)).not.toBe(devboxSourceDigest("desktop", dockerfile)); + expect(devboxSourceDigest("base", dockerfile)).toBe(devboxSourceDigest("base", `${dockerfile}\n# a comment changes no machine\n`)); + const codex = devboxAgentPins(dockerfile).find((pin) => pin.pkg === "@openai/codex")!; + expect(devboxSourceDigest("base", rewriteDevboxAgentPins(dockerfile, { "@openai/codex": `${codex.version}9` }))).not.toBe(devboxSourceDigest("base", dockerfile)); + expect(devboxSourceDigest("base", dockerfile.replace(/^ENV CMUX_IMAGE_EPOCH=.*$/m, "ENV CMUX_IMAGE_EPOCH=1999-01-01-r1"))).not.toBe(devboxSourceDigest("base", dockerfile)); + // Both bake entry points record the digest for the layers they baked. + expect(readScript("build-devbox-freestyle.ts")).toContain('bakeMetadata(preflight, fileURLToPath(import.meta.url), withDesktop ? "desktop" : "base")'); + expect(readScript("promote-devbox-image.ts")).toContain("devboxSourceDriftProblems(next)"); + expect(readScript("validate-devbox-ladder.ts")).toContain("devboxSourceDriftProblems(manifest)"); + }); + + test("codex's Linux sandbox prerequisite is installed and the first agent launch is verified", () => { + // bubblewrap: without the distro bwrap, codex 0.151+ warns on every + // launch that it is falling back to its bundled copy (seen live on the + // termid ladder, 2026-09-09). Both recipes install it and prove it. + expect(dockerfile).toContain(" bubblewrap \\"); + expect(dockerfile).toContain("bwrap --version"); + const bake = readScript("build-devbox-freestyle.ts"); + expect(bake).toContain("util-linux bubblewrap"); + expect(bake).toContain("bwrap --version"); + // The verifier launches the real claude and codex TUIs as root and as the + // work user and requires the ready composer with no first-run gate text. + const verify = readScript("verify-devbox-image.ts"); + expect(verify).toContain("bubblewrap-ok"); + for (const label of ["claude-root-launch", "claude-ubuntu-launch", "codex-root-launch", "codex-ubuntu-launch"]) { + expect(verify).toContain(`"${label}"`); + } + expect(verify).toContain('const CLAUDE_LAUNCH_MARKER = "bypass permissions on"'); + expect(verify).toContain('const CODEX_LAUNCH_MARKER = "Ask Codex to do anything"'); + expect(verify).toContain("...AGENT_LAUNCH_CHECKS,"); + // Readiness is polled on the marker, never a fixed sleep-then-read. + expect(verify).toContain("for i in $(seq 1 90); do pane="); + }); + test("one public-platform SDK serves the bake, the verifier, and the driver", () => { // There is a single Freestyle arm now: the public platform on freestyle@0.2.x. // A stray `freestyle-beta` alias would silently send one of these three at diff --git a/web/tests/vm-image-manifest.test.ts b/web/tests/vm-image-manifest.test.ts index e7bbb4d96ba..3ef8c0b6357 100644 --- a/web/tests/vm-image-manifest.test.ts +++ b/web/tests/vm-image-manifest.test.ts @@ -1,8 +1,16 @@ import { describe, expect, test } from "bun:test"; +import path from "node:path"; import { + bakeMetadata, + devboxImageEpoch, devboxImageLadderProblems, + devboxSourceDigest, + devboxSourceDriftProblems, imageManifestProblems, + manifestEntryEpoch, + manifestEntrySkeleton, promoteImageManifestEntry, + readDevboxDockerfile, readImageManifest, type DevboxImageManifest, type DevboxManifestEntry, @@ -24,7 +32,7 @@ const passedEntry = (overrides: Partial = {}): DevboxManife repoCommit: "abc123", builtAt: "2026-09-02T00:00:00.000Z", builderScriptVersion: "deadbeef", - agentToolResolvedVersions: { "@anthropic-ai/claude-code": "2.1.252" }, + agentToolResolvedVersions: { "@anthropic-ai/claude-code": "2.1.267" }, validationStatus: "passed", notes: "cmux devbox epoch test", ...overrides, @@ -47,6 +55,92 @@ describe("checked-in image manifest", () => { test("has a complete, shape-correct base and desktop ladder", () => { expect(devboxImageLadderProblems(readImageManifest())).toEqual([]); }); + + test("every default was baked from this checkout's devbox sources", () => { + // The manifest is the only source of truth for the image users get, so + // main must not describe a machine the promoted default is not: every + // default carries the Dockerfile's CMUX_IMAGE_EPOCH and, once recorded, + // the digest of the files and pins the bake took from this checkout. A + // pin or template change lands together with its promotion, and a + // rollback reverts the promotion commit whole (sources included). + expect(devboxSourceDriftProblems(readImageManifest())).toEqual([]); + }); +}); + +describe("devboxSourceDriftProblems", () => { + const dockerfile = readDevboxDockerfile(); + const epoch = devboxImageEpoch(dockerfile); + const current = (layers: "desktop" | "base", overrides: Partial = {}): DevboxManifestEntry => + passedEntry({ + kind: layers, + defaultForKind: true, + epoch, + devboxSource: { layers, digest: devboxSourceDigest(layers, dockerfile) }, + notes: `cmux devbox epoch ${epoch}`, + ...overrides, + }); + const manifestOf = (...images: DevboxManifestEntry[]): DevboxImageManifest => ({ schemaVersion: 1, images }); + + test("accepts defaults at the current epoch and digest, and ignores non-defaults and other providers", () => { + expect(devboxSourceDriftProblems(manifestOf(current("desktop"), current("base", { version: "b" })))).toEqual([]); + expect( + devboxSourceDriftProblems( + manifestOf( + current("desktop"), + passedEntry({ version: "old", imageId: "sh-old", defaultForKind: false, epoch: "1999-01-01-r1" }), + passedEntry({ version: "e2b", provider: "e2b" as unknown as DevboxManifestEntry["provider"], defaultForKind: true, epoch: "1999-01-01-r1" }), + ), + ), + ).toEqual([]); + }); + + test("flags a default baked at another epoch, read from the field or from the notes", () => { + const stale = devboxSourceDriftProblems(manifestOf(current("desktop", { epoch: "1999-01-01-r1" }))); + expect(stale).toHaveLength(1); + expect(stale[0]).toContain("baked at devbox epoch 1999-01-01-r1"); + expect(stale[0]).toContain(`the Dockerfile is at ${epoch}`); + // Older entries carry the epoch only in `notes`; a promoted ladder from before + // an epoch bump is the exact case this catches. + const legacy = passedEntry({ kind: "base", defaultForKind: true, notes: "cmux devbox epoch 1999-01-01-r1 Devbox on Freestyle." }); + expect(manifestEntryEpoch(legacy)).toBe("1999-01-01-r1"); + expect(devboxSourceDriftProblems(manifestOf(legacy))).toHaveLength(1); + expect(devboxSourceDriftProblems(manifestOf(passedEntry({ kind: "base", defaultForKind: true, notes: `cmux devbox epoch ${epoch}` })))).toEqual([]); + expect(manifestEntryEpoch(passedEntry({ notes: "no epoch here" }))).toBeUndefined(); + }); + + test("flags a default whose recorded source digest is not this checkout's, per layer set", () => { + const drifted = devboxSourceDriftProblems(manifestOf(current("desktop", { devboxSource: { layers: "desktop", digest: "0".repeat(64) } }))); + expect(drifted).toHaveLength(1); + expect(drifted[0]).toContain("baked from devbox sources 000000000000"); + // A desktop image promoted as the base kind is held to the desktop sources it was baked from. + expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "desktop", digest: devboxSourceDigest("desktop", dockerfile) } })))).toEqual([]); + expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "desktop", digest: devboxSourceDigest("base", dockerfile) } })))).toHaveLength(1); + expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "vnc" as "base", digest: "x" } })))[0]).toContain("is not desktop|base"); + // A Dockerfile change is what makes the checkout drift from the default. + const bumped = dockerfile.replace(/^ENV CMUX_IMAGE_EPOCH=.*$/m, "ENV CMUX_IMAGE_EPOCH=2099-01-01-r1"); + const problems = devboxSourceDriftProblems(manifestOf(current("desktop")), "freestyle", bumped); + expect(problems.some((problem) => problem.includes("baked at devbox epoch"))).toBe(true); + expect(problems.some((problem) => problem.includes("baked from devbox sources"))).toBe(true); + }); + + test("the bake records the epoch and the source digest, and promotion carries them onto every variant", () => { + const metadata = bakeMetadata({ sha: "abc123", epoch }, path.join(import.meta.dirname, "../scripts/build-devbox-freestyle.ts"), "desktop"); + expect(metadata.devboxSource).toEqual({ layers: "desktop", digest: devboxSourceDigest("desktop", dockerfile) }); + const entry = manifestEntrySkeleton("freestyle", "freestyle-x", "sh-x", "FREESTYLE_SANDBOX_SNAPSHOT", metadata, "", "desktop"); + expect(entry).toMatchObject({ epoch, devboxSource: metadata.devboxSource, validationStatus: "unknown" }); + const promoted = promoteImageManifestEntry(manifestOf(), { ...entry, validationStatus: "passed" }, { + kinds: ["desktop", "base"], + sizes: [ + { imageId: "sh-x-sm", size: { name: "sm", cpu: 2, memoryMb: 4096, storageMb: 16384 } }, + { imageId: "sh-x-md", size: { name: "md", cpu: 4, memoryMb: 8192, storageMb: 32768 } }, + ], + }); + expect(promoted.images).toHaveLength(4); + for (const row of promoted.images) { + expect(row).toMatchObject({ epoch, devboxSource: metadata.devboxSource, defaultForKind: true }); + } + expect(devboxSourceDriftProblems(promoted)).toEqual([]); + }); }); describe("devboxImageLadderProblems", () => { From 40a98b0c5fd9fe02a3481914b912dce493775d1a Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 9 Sep 2026 20:24:28 -0700 Subject: [PATCH 02/18] cloud: promote --replay and --sizes-result; judge only the written rows at promote time; document the pin and promotion workflow Two ladders are two bakes and two promotions, and only one promotion may write the manifest at a time. This makes that workflow sanctioned instead of hand-edited: - `promote --replay ` re-applies the rows an earlier promotion appended (its --out `entries`, or the rows from that PR's manifest diff) through the same append + demotion rule (`appendImageManifestEntries`, factored out of `promoteImageManifestEntry`): the way to land the second ladder after the first has written, and to resolve a manifest conflict between two promotion PRs. - `promote --sizes-result ` adopts an existing derive run (still re-verifies) so a promotion refused at the write does not derive and snapshot every size twice. - The promote-time drift check judges only the rows being written; the other kind's ladder is promoted by its own run, and CI holds the whole manifest to the invariant once both have landed. My first base promotion was refused by the whole-manifest check because the desktop defaults were still at the old epoch. - READMEs: the pins:check workflow, the epoch and source-digest invariants, rollback reverting the sources with the manifest, the parallel bake and sequential write flow, and conflict resolution by replay. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AxoApJ1xp8qCKmngR2Jgmx --- web/scripts/devbox-image-common.ts | 97 +++++++++++++------ web/scripts/promote-devbox-image.ts | 91 ++++++++++++++---- web/services/vms/README.md | 18 ++-- web/services/vms/images/devbox/README.md | 113 ++++++++++++++++++++--- web/tests/vm-devbox-image.test.ts | 9 +- web/tests/vm-image-manifest.test.ts | 68 ++++++++++++++ 6 files changed, 330 insertions(+), 66 deletions(-) diff --git a/web/scripts/devbox-image-common.ts b/web/scripts/devbox-image-common.ts index 63962821f8c..1ea5810a9b3 100644 --- a/web/scripts/devbox-image-common.ts +++ b/web/scripts/devbox-image-common.ts @@ -761,12 +761,75 @@ function sizeKey(entry: Pick): string { return entry.size?.name ?? ""; } +/** + * Appends fully-formed rows (each a promotion's output: `kind`, + * `defaultForKind`, `size`, `validationStatus`) to the manifest, demoting the + * provider's existing defaults for every kind+size a default row takes over + * (a sized row also retires the size-less defaults of its kind: the ladder + * replaces the single-shape image; a row carrying `defaultForLocalDev` + * retires the previous one). Pure: returns a new manifest and never mutates + * the input. Existing entries are only ever flag-flipped, never removed, so + * rollback stays a one-line manifest change. This is the one edit a + * promotion performs, and `promote --replay ` re-applies the + * rows an earlier run appended onto a manifest that changed underneath it + * (another ladder merged first), so a merge conflict is resolved through the + * sanctioned writer and never by hand. + */ +export function appendImageManifestEntries( + manifest: DevboxImageManifest, + rows: readonly DevboxManifestEntry[], +): DevboxImageManifest { + if (rows.length === 0) throw new Error("refusing to append no manifest rows"); + for (const row of rows) { + if (!row.provider || !row.version || !row.imageId) { + throw new Error(`refusing to append a row without provider, version and imageId: ${JSON.stringify(row).slice(0, 200)}`); + } + if (row.defaultForKind && row.validationStatus !== "passed") { + throw new Error( + `refusing to promote ${row.provider} ${row.imageId}: validationStatus is ` + + `${row.validationStatus}, not passed (run verify-devbox-image.ts first)`, + ); + } + const clash = manifest.images.find((candidate) => + candidate.provider === row.provider && + candidate.imageId === row.imageId && + (candidate.kind ?? "base") === (row.kind ?? "base") && + sizeKey(candidate) === sizeKey(row) + ); + if (clash) { + throw new Error( + `refusing to promote ${row.provider} ${row.imageId}: already listed as ${clash.version} (${row.kind ?? "base"}${row.size ? `, ${row.size.name}` : ""})`, + ); + } + } + // provider/kind -> the size keys its new default rows take over. + const takeover = new Map>(); + const localDevProviders = new Set(); + for (const row of rows) { + if (row.defaultForLocalDev) localDevProviders.add(row.provider); + if (!row.defaultForKind) continue; + const key = `${row.provider}/${row.kind ?? "base"}`; + takeover.set(key, new Set([...(takeover.get(key) ?? []), sizeKey(row)])); + } + const providers = new Set(rows.map((row) => row.provider)); + const demoted = manifest.images.map((candidate) => { + if (!providers.has(candidate.provider)) return candidate; + const next: DevboxManifestEntry = { ...candidate }; + if (localDevProviders.has(next.provider) && next.defaultForLocalDev) next.defaultForLocalDev = false; + const sizes = takeover.get(`${next.provider}/${next.kind ?? "base"}`); + if (sizes && next.defaultForKind) { + const sized = [...sizes].some((size) => size !== ""); + if (sizes.has(sizeKey(next)) || (sizeKey(next) === "" && sized)) next.defaultForKind = false; + } + return next; + }); + return { schemaVersion: manifest.schemaVersion, images: [...demoted, ...rows] }; +} + /** * Appends a verified image to the manifest as the default for every kind in * `kinds` (and every size in `sizes`), demoting the provider's previous - * defaults for those kind+size pairs. Pure: returns a new manifest and never - * mutates the input. Existing entries are only ever flag-flipped, never - * removed, so rollback stays a one-line manifest change. + * defaults for those kind+size pairs (appendImageManifestEntries). Pure. */ export function promoteImageManifestEntry( manifest: DevboxImageManifest, @@ -788,32 +851,6 @@ export function promoteImageManifestEntry( ? [...options.sizes].sort((a, b) => vmImageSizeRank(a.size.name) - vmImageSizeRank(b.size.name)) : [{ imageId: entry.imageId }]; const promotesLocalDevBase = kinds.includes("base") && variants.some((variant) => variant.size?.name === "sm"); - for (const kind of kinds) { - for (const variant of variants) { - const clash = manifest.images.find((candidate) => - candidate.provider === entry.provider && - candidate.imageId === variant.imageId && - (candidate.kind ?? "base") === kind && - sizeKey(candidate) === (variant.size?.name ?? "") - ); - if (clash) { - throw new Error( - `refusing to promote ${entry.provider} ${variant.imageId}: already listed as ${clash.version} (${kind}${variant.size ? `, ${variant.size.name}` : ""})`, - ); - } - } - } - const promotedSizes = new Set(variants.map((variant) => variant.size?.name ?? "")); - const demoted = manifest.images.map((candidate) => { - if (candidate.provider !== entry.provider) return candidate; - const next: DevboxManifestEntry = { ...candidate }; - if (promotesLocalDevBase && next.provider === entry.provider && next.defaultForLocalDev) next.defaultForLocalDev = false; - // A sized promotion demotes the provider's size-less defaults too: the - // ladder replaces the single-shape image, not just one row of it. - const sameSize = promotedSizes.has(sizeKey(next)) || (sizeKey(next) === "" && promotedSizes.size > 0); - if (next.defaultForKind && kinds.includes(next.kind ?? "base") && sameSize) next.defaultForKind = false; - return next; - }); const notes = [entry.notes, options.validationNotes].filter(Boolean).join(" "); const promoted: DevboxManifestEntry[] = []; for (const kind of kinds) { @@ -833,7 +870,7 @@ export function promoteImageManifestEntry( }); } } - return { schemaVersion: manifest.schemaVersion, images: [...demoted, ...promoted] }; + return appendImageManifestEntries(manifest, promoted); } /** diff --git a/web/scripts/promote-devbox-image.ts b/web/scripts/promote-devbox-image.ts index fe5f123d611..1cef843f478 100644 --- a/web/scripts/promote-devbox-image.ts +++ b/web/scripts/promote-devbox-image.ts @@ -14,6 +14,9 @@ * --image promote an already-baked image (still verified) instead of baking. * --bake-result adopt a bake script's --out file (its manifest entry * and image id) instead of baking; still verified. + * --sizes-result adopt a derive-devbox-sizes.ts --out file instead + * of deriving again (the sizes already exist on the account, + * each booted and checked by that run); still verified. * --sizes ladder sizes to derive from the verified bake (default * sm,md,lg,lgx,xl,2xl; "none" records a single size-less entry): * derive-devbox-sizes.ts boots the bake, resizes, snapshots and @@ -37,6 +40,13 @@ * --skip-verify record validationStatus "unknown" instead of verifying. * The entry is appended but NOT flagged as any default. * --dry-run print the manifest diff without writing it. + * --replay re-apply the rows an earlier promotion appended (the + * `entries` of its --out summary, or those rows copied from + * that PR's manifest diff) onto the current manifest: no bake, + * verify, derive or pointer move, only the manifest edit with + * the same demotion rule and invariants. For a manifest that + * changed underneath a promotion (another ladder merged first): + * merge main, take main's manifest, replay, commit. * * Steps: bakePreflight (stale checkout guard) -> bake script (--out) -> * verify-devbox-image.ts (boots one VM, deletes it) -> manifest write -> @@ -52,6 +62,7 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import { VM_IMAGE_SIZE_NAMES, isVmImageSizeName, type VmImageSize } from "../services/vms/images/sizes"; import { + appendImageManifestEntries, argValue, bakeMetadata, bakePreflight, @@ -67,6 +78,7 @@ import { writeImageManifest, type DevboxBakeResult, type DevboxImageKind, + type DevboxImageManifest, type DevboxManifestEntry, type DevboxProvider, } from "./devbox-image-common"; @@ -113,6 +125,49 @@ function run(label: string, args: string[]): number { return result.status ?? 1; } +/** Re-check the invariants and write (or print) the edited manifest; returns the appended rows. */ +function commitManifest(label: string, manifest: DevboxImageManifest, next: DevboxImageManifest, drift: boolean): DevboxManifestEntry[] { + const added = next.images.slice(manifest.images.length); + // The rows being written must describe the machine this checkout + // describes: they carry its epoch and source digest, so a stale bake (an + // --image baked before a Dockerfile change, an epoch bumped after the bake) + // is refused here rather than caught by CI after the PR is open. Only the + // new rows are judged: the other kind's ladder is promoted by its own run + // (desktop and base are two bakes), and CI holds the whole manifest to the + // invariant once both have landed. + const problems = [...imageManifestProblems(next), ...(drift ? devboxSourceDriftProblems({ ...next, images: added }) : [])]; + if (problems.length > 0) { + throw new Error(`refusing to write an inconsistent manifest:\n ${problems.join("\n ")}`); + } + console.log(`\n===== ${label} =====\n${JSON.stringify(added, null, 2)}`); + if (dryRun) { + console.log(`--dry-run: not writing ${imageManifestPath}`); + } else { + writeImageManifest(next); + console.log(`wrote ${imageManifestPath} (+${added.length} entries)`); + } + return added; +} + +// 0. Replay (see the header): only the manifest edit, from rows that already +// carry their verify outcome and derived ids. +const replayPath = argValue("--replay"); +if (replayPath) { + const parsed = JSON.parse(readFileSync(replayPath, "utf8")) as { entries?: DevboxManifestEntry[] } | DevboxManifestEntry[]; + const rows = Array.isArray(parsed) ? parsed : (parsed.entries ?? []); + if (rows.length === 0) throw new Error(`--replay ${replayPath}: no entries to replay`); + for (const row of rows) { + if (row.provider !== provider) throw new Error(`--replay ${replayPath}: ${row.version} is a ${row.provider} row, not ${provider}`); + } + const manifest = readImageManifest(); + const added = commitManifest(`manifest (replay of ${replayPath})`, manifest, appendImageManifestEntries(manifest, rows), true); + const replayResult = { provider, replayedFrom: replayPath, versions: added.map((row) => row.version), entries: added, manifest: dryRun ? null : imageManifestPath }; + console.log(JSON.stringify(replayResult, null, 2)); + const replayOut = argValue("--out"); + if (replayOut) writeFileSync(replayOut, `${JSON.stringify(replayResult, null, 2)}\n`); + process.exit(0); +} + // 1. Bake (or adopt an existing image / a previous bake's result file). let entry: DevboxManifestEntry; let imageId: string; @@ -179,9 +234,22 @@ if (skipVerify) { (withDesktop ? " (toolchain, agent pins, daemon contract, desktop on 5901/6901)." : " (toolchain, agent pins, daemon contract)."); } -// 2b. Sizes: derive the ladder from the verified bake, each booted and checked. +// 2b. Sizes: derive the ladder from the verified bake, each booted and checked +// (or adopt a derive run's result: the snapshots exist and were checked then). let sizes: Array<{ imageId: string; size: VmImageSize }> | undefined; -if (!skipVerify && sizeNames.length > 0) { +const sizesResultPath = argValue("--sizes-result"); +if (!skipVerify && sizesResultPath) { + const derived = JSON.parse(readFileSync(sizesResultPath, "utf8")) as { master?: string; sizes: Record }; + if (derived.master !== imageId) { + throw new Error(`--sizes-result ${sizesResultPath} was derived from ${derived.master ?? "(unknown)"}, not ${imageId}`); + } + const rows = Object.values(derived.sizes); + const missing = sizeNames.filter((name) => !rows.some((row) => row.size.name === name)); + if (missing.length > 0) throw new Error(`--sizes-result ${sizesResultPath} lacks sizes ${missing.join(", ")}`); + sizes = rows.filter((row) => sizeNames.includes(row.size.name)).map((row) => ({ imageId: row.imageId, size: row.size })); + console.log(`adopting derived sizes ${sizesResultPath}: ${sizes.map((row) => `${row.size.name}=${row.imageId}`).join(", ")}`); + validationNotes += ` Sizes derived and re-booted by derive-devbox-sizes.ts: ${sizes.map((row) => `${row.size.name}=${row.imageId}`).join(", ")}.`; +} else if (!skipVerify && sizeNames.length > 0) { const sizesOut = path.join(workDir, "sizes.json"); // "none" means "leave the shared pointer slugs alone", never a literal // prefix: a branch bake's sizes are slugged under its own slug. @@ -210,22 +278,7 @@ const manifest = readImageManifest(); const next = skipVerify ? { ...manifest, images: [...manifest.images, { ...entry, kind: kinds[0], notes: [entry.notes, validationNotes].filter(Boolean).join(" ") }] } : promoteImageManifestEntry(manifest, entry, { kinds, sizes, validationNotes }); -// The manifest must keep describing the machine users get: the new defaults -// carry this checkout's epoch and source digest, so a stale bake (an --image -// baked before a Dockerfile change, an epoch bumped after the bake) is refused -// here rather than caught by CI after the PR is open. -const problems = [...imageManifestProblems(next), ...(skipVerify ? [] : devboxSourceDriftProblems(next))]; -if (problems.length > 0) { - throw new Error(`refusing to write an inconsistent manifest:\n ${problems.join("\n ")}`); -} -const added = next.images.slice(manifest.images.length); -console.log(`\n===== manifest =====\n${JSON.stringify(added, null, 2)}`); -if (dryRun) { - console.log(`--dry-run: not writing ${imageManifestPath}`); -} else { - writeImageManifest(next); - console.log(`wrote ${imageManifestPath} (+${added.length} entries)`); -} +const added = commitManifest("manifest", manifest, next, !skipVerify); // 4. Pointer slug: a readable "current" handle on the platform. With sizes, // derive-devbox-sizes.ts already named each snapshot `[-]`. @@ -257,6 +310,8 @@ const result = { validationStatus: entry.validationStatus, pointerSlug: pointer, manifest: dryRun ? null : imageManifestPath, + // The exact rows appended, so `--replay` can re-apply this promotion. + entries: added, }; console.log(JSON.stringify(result, null, 2)); const out = argValue("--out"); diff --git a/web/services/vms/README.md b/web/services/vms/README.md index a8af22b027e..517e14f499a 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -162,18 +162,24 @@ Image policy: before private networking) gets an error rather than a public URL. - Baked agent tools are installed at image-build time. They are not auto-updated on VM startup, so startup latency stays bounded and the manifest remains the source of truth. -- To update tool versions, bump the Dockerfile ARG pins and `CMUX_IMAGE_EPOCH`, then promote a new - image. `CMUX_CLOUD_IMAGE__NPM_SPEC` overrides must be exact npm package version pins, for - example `@openai/codex@0.130.0`, or `none` to disable a tool. The image builder rejects ranges - and tags such as `latest`. +- To update tool versions, run `bun run devbox:pins:check --write` (web/; it rewrites the Dockerfile + ARG pins to the npm registry's current releases and refuses ranges and tags), bump + `CMUX_IMAGE_EPOCH`, then promote both ladders. `tests/vm-image-manifest.test.ts` and + `devbox:manifest:check` fail while a default is baked at another epoch or from other devbox + sources than the checkout (`devboxSourceDriftProblems`), so a pin bump and its promotion land + in one PR and never drift apart. `CMUX_CLOUD_IMAGE__NPM_SPEC` overrides must be exact npm + package version pins, for example `@openai/codex@0.130.0`, or `none` to disable a tool. The + image builder rejects ranges and tags such as `latest`. A leftover `FREESTYLE_SANDBOX_SNAPSHOT` in a deployment is ignored; the env audit reports it as stale configuration to remove. Rollback is a manifest change: -1. Revert the promotion PR (or flip `defaultForKind` back to a previous entry with - `validationStatus: "passed"`; entries are never removed). +1. Revert the promotion PR as a whole (entries are never removed). Flipping `defaultForKind` back + to a previous `validationStatus: "passed"` entry by hand also means reverting the Dockerfile + epoch and pins that entry was baked from, or `devbox:manifest:check` and the manifest test fail + on the epoch and source-digest invariants. 2. Deploy staging, smoke test, then production. 3. Keep old snapshots until all VMs using them are gone. diff --git a/web/services/vms/images/devbox/README.md b/web/services/vms/images/devbox/README.md index 14fd2af8271..5b4cea51445 100644 --- a/web/services/vms/images/devbox/README.md +++ b/web/services/vms/images/devbox/README.md @@ -21,10 +21,51 @@ Python 3.12, uv, Docker (running from boot), and its own copies of Claude Code, Codex and OpenCode. The bake keeps all of that and replaces the agent copies with the exact Dockerfile pins (`npm install -g` on the base's npm, every agent bin symlinked into `/usr/local/bin` so daemon panes resolve them -without a login profile). The work user is the base's **`ubuntu`** (uid -1000, passwordless sudo, the API's default exec user and the SSH default); -the bake creates no users. A cmux login banner (`cmux-motd`, rendered by -pam_motd on SSH) replaces the stock Ubuntu and Freestyle motd text. +without a login profile), and installs `bubblewrap`, codex's Linux sandbox +prerequisite, so codex runs on the distro's `bwrap` instead of warning on +every launch that it is falling back to its bundled copy. The work user is +the base's **`ubuntu`** (uid 1000, passwordless sudo, the API's default exec +user and the SSH default); the bake creates no users. A cmux login banner +(`cmux-motd`, rendered by pam_motd on SSH) replaces the stock Ubuntu and +Freestyle motd text. + +## Agent pins: bump, epoch, promote + +The coding agents are exact npm releases in the Dockerfile's `ARG +CMUX_IMAGE__VERSION` lines, and machines never self-update +(`DISABLE_AUTOUPDATER=1` for Claude Code, `check_for_update_on_startup = +false` for codex), so a new Claude Code or Codex reaches cmux Cloud only +through a rebake: + +```bash +# from web/ +bun run devbox:pins:check # pins next to the npm registry's current releases; exit 1 when behind +bun run devbox:pins:check --write # rewrite the ARG lines to those releases +``` + +then bump `CMUX_IMAGE_EPOCH` in the same file and promote both ladders (see +"Promote" below). `--write` touches only the ARG lines and refuses ranges, +tags and packages the image does not bake; the chatmux devbox template +(`chatmux:infra/sandbox-images/Dockerfile`) is bumped by hand in its own +repo to keep the parity the header describes. + +Two invariants keep the checked-in manifest describing the machine users get +(`devboxSourceDriftProblems` in `devbox-image-common.ts`, run by +`devbox:manifest:check`, `vm-image-manifest.test.ts` and `promote` before it +writes): + +- every `defaultForKind` entry was baked at the Dockerfile's current + `CMUX_IMAGE_EPOCH` (the entry's `epoch`, or the `cmux devbox epoch` prefix + of its `notes` on older entries), so an epoch bump lands together with its + promotion and a rollback to an older ladder also reverts the sources; +- an entry that recorded `devboxSource` (`{ layers, digest }`, + `devboxSourceDigest()`: sha256 over the files the bake ships verbatim, the + agent, cua-driver and Ghostty pins, the desktop apt list and the epoch, + per layer set; Dockerfile prose is excluded because a comment cannot change + a machine) was baked from exactly this checkout's sources. + +Rollback is therefore a revert of the promotion commit as a whole, never the +manifest flags alone. `vm-devbox-image.test.ts` pins the shared files (`cmux-bashrc`, `agent-config.sh`, `seed-history`, `chrome-managed-policy.json`) to their @@ -275,10 +316,55 @@ passing verify derives the sizes and writes the manifest, appending one entry per kind and size flagged `defaultForKind` while demoting the provider's previous defaults for those kind+size pairs (a sized promotion also demotes size-less defaults: the ladder replaces the single-shape -image). Existing entries are never removed, so rollback is a manifest -revert. The last stdout line is `IMAGE_ID ` (the bake); `--out ` -writes the summary with every derived id. Commit the manifest diff in a PR; -merging it is the promotion. +image). Before writing it re-checks the manifest invariants and the source +drift invariants above, so a bake from another epoch or other sources is +refused rather than caught by CI. Existing entries are never removed, so +rollback is a manifest revert. The last stdout line is `IMAGE_ID ` (the +bake); `--out ` writes the summary with every derived id. Commit the +manifest diff in a PR; merging it is the promotion. + +The desktop and base ladders are two bakes. They can bake, verify and +derive in parallel, but only one promotion may write the manifest at a time +(two concurrent writes would lose one ladder), so run the second with +`--dry-run --out ` and land its rows with `--replay` once the first +has written: + +```bash +bun run devbox:bake:freestyle cmux-devbox- --out /tmp/desktop.json +bun run devbox:bake:freestyle cmux-devbox--base --no-desktop --out /tmp/base.json +bun run devbox:promote -- freestyle --bake-result /tmp/base.json --no-desktop --kinds base --pointer-slug cmux-devbox--base +bun run devbox:promote -- freestyle --bake-result /tmp/desktop.json --kinds desktop --pointer-slug cmux-devbox- --dry-run --out /tmp/desktop-summary.json +bun run devbox:promote -- freestyle --replay /tmp/desktop-summary.json +``` + +A promotion that verified and derived but did not write (a refused write, a +crash after `derive-devbox-sizes.ts`) is resumed without re-deriving: +`--bake-result --sizes-result ` re-verifies the +bake and adopts the derived ids (they already exist on the account, each +booted and checked by that run). + +Pin the daemon for both with `CMUX_VM_CMUX_TUI_MANIFEST_URL` (one commit's +`https://files.cmux.com/cmux-tui//manifest.json`) so the two ladders +cannot straddle an artifacts publish. + +### Two promotions in flight + +Two PRs that each promote a ladder conflict on `manifest.json` (both append +rows and flip the same defaults). Whichever merges second resolves it through +the writer, never by hand: merge `main` taking main's manifest wholesale, then +replay the rows the promotion appended (the `entries` of its `--out` summary, +or those rows copied from the PR's manifest diff): + +```bash +bun run devbox:promote -- freestyle --replay /tmp/desktop-summary.json +bun run devbox:promote -- freestyle --replay /tmp/base-summary.json +``` + +`--replay` performs only the manifest edit (`appendImageManifestEntries`: the +same append, clash check and demotion rule as a promotion, followed by the +invariants), no bake, verify, derive or slug move: the rows already carry +their verify outcome and derived ids. The other PR's rows stay listed, +demoted, for rollback. ## Bake and verify by hand @@ -307,9 +393,14 @@ the artifacts manifest at deploy time (`CMUX_VM_CMUX_TUI_MANIFEST_URL`), never from the image. Each bake prints a `next` command. The verifier boots one VM from the -snapshot, asserts the toolchain, the exact agent pins, ghost text -under a tmux PTY, byte-identical baked files, the work user, and (when -`/etc/cmux/image-stamp` says `desktop`) the desktop contract (both ports, +snapshot, asserts the toolchain, the exact agent pins, `bwrap`, ghost text +under a tmux PTY, byte-identical baked files, the work user, the first +interactive launch of `claude --dangerously-skip-permissions` and of `codex` +as root and as `ubuntu` reaching the ready composer with no first-run gate +on screen (onboarding, folder trust, the bypass confirmation, the custom +API key consent, the root gate, codex's update picker and bubblewrap +warning; readiness is the composer text itself, polled and bounded), and +(when `/etc/cmux/image-stamp` says `desktop`) the desktop contract (both ports, RFB loopback-only, the session processes, the wallpaper on the root window, one supervisor, `DISPLAY` in root's and `ubuntu`'s login shells, `cua-driver doctor` seeing the display and the accessibility bus, every diff --git a/web/tests/vm-devbox-image.test.ts b/web/tests/vm-devbox-image.test.ts index 47f21e48b86..345dcd4cbe1 100644 --- a/web/tests/vm-devbox-image.test.ts +++ b/web/tests/vm-devbox-image.test.ts @@ -423,8 +423,15 @@ describe("devbox image template", () => { expect(devboxSourceDigest("base", dockerfile.replace(/^ENV CMUX_IMAGE_EPOCH=.*$/m, "ENV CMUX_IMAGE_EPOCH=1999-01-01-r1"))).not.toBe(devboxSourceDigest("base", dockerfile)); // Both bake entry points record the digest for the layers they baked. expect(readScript("build-devbox-freestyle.ts")).toContain('bakeMetadata(preflight, fileURLToPath(import.meta.url), withDesktop ? "desktop" : "base")'); - expect(readScript("promote-devbox-image.ts")).toContain("devboxSourceDriftProblems(next)"); + expect(readScript("promote-devbox-image.ts")).toContain("devboxSourceDriftProblems({ ...next, images: added })"); expect(readScript("validate-devbox-ladder.ts")).toContain("devboxSourceDriftProblems(manifest)"); + // A promotion records the rows it appended and can replay them onto a + // manifest that changed underneath it (two ladders in flight), through the + // same append + demotion rule, never by hand. + const promote = readScript("promote-devbox-image.ts"); + expect(promote).toContain('argValue("--replay")'); + expect(promote).toContain("appendImageManifestEntries(manifest, rows)"); + expect(promote).toContain("entries: added,"); }); test("codex's Linux sandbox prerequisite is installed and the first agent launch is verified", () => { diff --git a/web/tests/vm-image-manifest.test.ts b/web/tests/vm-image-manifest.test.ts index 3ef8c0b6357..e0cbc222d99 100644 --- a/web/tests/vm-image-manifest.test.ts +++ b/web/tests/vm-image-manifest.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; import { + appendImageManifestEntries, bakeMetadata, devboxImageEpoch, devboxImageLadderProblems, @@ -242,6 +243,73 @@ describe("promoteImageManifestEntry", () => { }); }); +describe("appendImageManifestEntries (promote --replay)", () => { + // Two promotions in flight append to the same manifest. Whichever merges + // second replays the rows its promotion appended (the --out summary's + // `entries`) onto the manifest as merged: the other ladder is demoted for + // every kind+size the replayed rows take over, nothing is removed, and the + // outcome is byte-identical to having promoted after the merge. + const size = (name: "sm" | "md", memoryMb: number) => ({ name, cpu: 2, memoryMb, storageMb: 16384 }); + const ladder = (tag: string, kind: "desktop" | "base", epoch: string): DevboxManifestEntry[] => + (["sm", "md"] as const).map((name, index) => + passedEntry({ + version: `freestyle-${tag}-${kind}-${name}`, + imageId: `sh-${tag}-${kind}-${name}`, + kind, + defaultForKind: true, + size: size(name, index === 0 ? 4096 : 8192), + epoch, + notes: `cmux devbox epoch ${epoch}`, + ...(kind === "base" && name === "sm" ? { defaultForLocalDev: true } : {}), + }), + ); + const main: DevboxImageManifest = { schemaVersion: 1, images: [...ladder("old", "desktop", "e1"), ...ladder("old", "base", "e1")] }; + + test("replaying a promotion onto a manifest that gained another ladder demotes that ladder and appends", () => { + const theirs = appendImageManifestEntries(main, ladder("theirs", "base", "e1")); + const mine = ladder("mine", "base", "e2"); + const replayed = appendImageManifestEntries(theirs, mine); + expect(imageManifestProblems(replayed)).toEqual([]); + expect(replayed.images.map((e) => [e.version, e.defaultForKind, e.defaultForLocalDev ?? false])).toEqual([ + ["freestyle-old-desktop-sm", true, false], + ["freestyle-old-desktop-md", true, false], + ["freestyle-old-base-sm", false, false], + ["freestyle-old-base-md", false, false], + ["freestyle-theirs-base-sm", false, false], + ["freestyle-theirs-base-md", false, false], + ["freestyle-mine-base-sm", true, true], + ["freestyle-mine-base-md", true, false], + ]); + // Same result as promoting in the other order, up to row order. + const otherOrder = appendImageManifestEntries(appendImageManifestEntries(main, mine), ladder("theirs", "base", "e1")); + expect(otherOrder.images.filter((e) => e.defaultForKind).map((e) => e.version)).toEqual( + ["freestyle-old-desktop-sm", "freestyle-old-desktop-md", "freestyle-theirs-base-sm", "freestyle-theirs-base-md"], + ); + // Pure: inputs untouched. + expect(theirs.images.find((e) => e.version === "freestyle-theirs-base-sm")?.defaultForKind).toBe(true); + }); + + test("a sized ladder retires size-less defaults of its kind; a size-less row leaves sized defaults alone", () => { + const sizeless = passedEntry({ version: "freestyle-flat", imageId: "sh-flat", kind: "desktop", defaultForKind: true }); + const withFlat: DevboxImageManifest = { schemaVersion: 1, images: [sizeless] }; + const sized = appendImageManifestEntries(withFlat, ladder("new", "desktop", "e1")); + expect(sized.images[0].defaultForKind).toBe(false); + const flatOnSized = appendImageManifestEntries(main, [passedEntry({ version: "freestyle-flat2", imageId: "sh-flat2", kind: "desktop", defaultForKind: true })]); + expect(flatOnSized.images.filter((e) => e.kind === "desktop" && e.defaultForKind).map((e) => e.version)).toEqual([ + "freestyle-old-desktop-sm", + "freestyle-old-desktop-md", + "freestyle-flat2", + ]); + }); + + test("refuses rows that are not promotable", () => { + expect(() => appendImageManifestEntries(main, [])).toThrow(/no manifest rows/); + expect(() => appendImageManifestEntries(main, [passedEntry({ version: "x", imageId: "sh-old-desktop-sm", kind: "desktop", size: size("sm", 4096) })])).toThrow(/already listed as freestyle-old-desktop-sm \(desktop, sm\)/); + expect(() => appendImageManifestEntries(main, [passedEntry({ version: "y", imageId: "sh-y", kind: "base", defaultForKind: true, validationStatus: "unknown" })])).toThrow(/validationStatus is unknown, not passed/); + expect(() => appendImageManifestEntries(main, [{ ...passedEntry(), version: "" }])).toThrow(/without provider, version and imageId/); + }); +}); + describe("imageManifestProblems", () => { test("flags two defaults for one provider+kind and an unvalidated default", () => { const bad: DevboxImageManifest = { From 6b7cf214ca1d1b99ec3d23580c6bd46b9f9f582f Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 9 Sep 2026 20:34:05 -0700 Subject: [PATCH 03/18] cloud: promote the cmux-devbox-agents0909 desktop and base ladders (epoch 2026-09-09-r1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Baked from d3b2da01be on freestyle/ubuntu-sm with the cmux-tui daemon pinned to 65ac4c2fe7 (files.cmux.com/cmux-tui/65ac4c2fe7…/manifest.json), verified by verify-devbox-image.ts (both ladders, the desktop contract, the first interactive claude and codex launches as root and ubuntu, bwrap), sizes derived and re-booted by derive-devbox-sizes.ts, and recorded by promote-devbox-image.ts: the base ladder with --sizes-result after its first write was refused, the desktop ladder verified and derived with --dry-run and landed with --replay. Desktop: sm sh-69841eb8073046df92d2cca9cc8b4ba2, md sh-fb1dba56f4b24616adfba52e6c935317, lg sh-68da64518f6c49a1923788f503d3492a, lgx sh-eec416ba245544a494d3be4ebe6022a2, xl sh-6d635b1cd223434aa9e1ad1a8aea1c45, 2xl sh-869c2c63c2cb4f399d19b5fdb1fee50f. Base: sm sh-0d4c81c5188140dba93a17eaac1608e0, md sh-7e6e24521b4f4b00b7478313fddedd7e, lg sh-e152ff6d015e45289a27104557374403, lgx sh-edb83b4aa34a4d79946b5d80fedf8921, xl sh-e27f9d8a81cc491080f6dd186d6f4808, 2xl sh-a370d08c8f934d9aa1935613c3b28cd8. Every entry carries epoch 2026-09-09-r1 and its devbox source digest, and bakes Claude Code 2.1.267, Codex 0.154.0, opencode 1.18.30, pi 0.85.1, agent-browser 0.37.1. The termid ladders stay listed, demoted, for rollback. The manifest test and devbox:manifest:check are green again. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01AxoApJ1xp8qCKmngR2Jgmx --- web/services/vms/README.md | 22 +- web/services/vms/images/manifest.json | 443 +++++++++++++++++++++++++- 2 files changed, 444 insertions(+), 21 deletions(-) diff --git a/web/services/vms/README.md b/web/services/vms/README.md index 517e14f499a..d643115a521 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -135,15 +135,19 @@ Image policy: `expectNoCloudVmImplementationLeaks` in `tests/vm-route-auth.test.ts`). - Local development and every deployed runtime serve the same `defaultForKind` entry; there is no separate local default and nothing to copy into `.env`. -- Today's default (both kinds, every size) is the `freestyle-cmux-devbox-11761b` ladder, baked and - verified on cmux's Freestyle account from https://github.com/manaflow-ai/cmux/pull/11776 - (`090e3daddd`, epoch `2026-09-02-r4`: the desktop session with owner-signalled readiness - (`Type=notify`), the accessibility bus, clipboard helper and published `DISPLAY`, baked cmux-tui - daemon, `freestyle/ubuntu-sm` base): `sm` `sh-60effaffd5404e5ab8dbdb08bd5f5eed`, `md` - `sh-1ce6c11f5d6e4f8e98c19454e9a38751`, `lg` `sh-bda89603f1ab41a2902ac5d781e2c6ce`, `xl` - `sh-95b526e17c234593a45edfb572e49396`, `2xl` `sh-236a1866dd244082ba0f06829df2358d`. The retired - beta entry stays listed for the record and is never a default; earlier public entries (the - `11761a`, `20260903b` and `edge1` ladders before it) stay for rollback. +- Today's defaults are the `freestyle-cmux-devbox-agents0909` ladders, baked and verified on cmux's + Freestyle account from https://github.com/manaflow-ai/cmux/issues/12244 (branch commit + `d3b2da01be`, epoch `2026-09-09-r1`: Claude Code 2.1.267, Codex 0.154.0, opencode 1.18.30, pi + 0.85.1, agent-browser 0.37.1, bubblewrap for codex's sandbox, the cmux-tui daemon `65ac4c2fe7`, + `freestyle/ubuntu-sm` base). Desktop (`kind: desktop`): `sm` `sh-69841eb8073046df92d2cca9cc8b4ba2`, + `md` `sh-fb1dba56f4b24616adfba52e6c935317`, `lg` `sh-68da64518f6c49a1923788f503d3492a`, `lgx` + `sh-eec416ba245544a494d3be4ebe6022a2`, `xl` `sh-6d635b1cd223434aa9e1ad1a8aea1c45`, `2xl` + `sh-869c2c63c2cb4f399d19b5fdb1fee50f`. Base (`kind: base`, shell-only): `sm` + `sh-0d4c81c5188140dba93a17eaac1608e0`, `md` `sh-7e6e24521b4f4b00b7478313fddedd7e`, `lg` + `sh-e152ff6d015e45289a27104557374403`, `lgx` `sh-edb83b4aa34a4d79946b5d80fedf8921`, `xl` + `sh-e27f9d8a81cc491080f6dd186d6f4808`, `2xl` `sh-a370d08c8f934d9aa1935613c3b28cd8`. Earlier + ladders (`termid`, `trust4`, `11761b`, and the rest) stay listed, demoted, for rollback; the + retired beta entry stays listed for the record and is never a default. - Snapshots are account-scoped: a manifest id is only bootable by the Freestyle account whose `FREESTYLE_API_KEY` the deployment uses; promote under cmux's key. - Promotion is `bun run devbox:promote -- freestyle` (bake → verify → manifest write), then a PR diff --git a/web/services/vms/images/manifest.json b/web/services/vms/images/manifest.json index dbded7fe9b6..478c56e06da 100644 --- a/web/services/vms/images/manifest.json +++ b/web/services/vms/images/manifest.json @@ -4331,7 +4331,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f44ae34ac45346e7b929a05fd14f5e0c, md=sh-8b74d9b034554291a22155e99e40bb9c, lg=sh-96120366dd584ff7bbb2a400c21ad57d, lgx=sh-cc6a1f10e072403487294c10e8373ec5, xl=sh-d5a2a58974c945c78cb9e18e707ffd72, 2xl=sh-43af960b7d3a42aea71a2c2eed6397d5.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "sm", "cpu": 2, @@ -4339,7 +4339,7 @@ "storageMb": 16384, "freestyleBase": "freestyle/ubuntu-sm" }, - "defaultForLocalDev": true + "defaultForLocalDev": false }, { "provider": "freestyle", @@ -4362,7 +4362,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f44ae34ac45346e7b929a05fd14f5e0c, md=sh-8b74d9b034554291a22155e99e40bb9c, lg=sh-96120366dd584ff7bbb2a400c21ad57d, lgx=sh-cc6a1f10e072403487294c10e8373ec5, xl=sh-d5a2a58974c945c78cb9e18e707ffd72, 2xl=sh-43af960b7d3a42aea71a2c2eed6397d5.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "md", "cpu": 4, @@ -4392,7 +4392,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f44ae34ac45346e7b929a05fd14f5e0c, md=sh-8b74d9b034554291a22155e99e40bb9c, lg=sh-96120366dd584ff7bbb2a400c21ad57d, lgx=sh-cc6a1f10e072403487294c10e8373ec5, xl=sh-d5a2a58974c945c78cb9e18e707ffd72, 2xl=sh-43af960b7d3a42aea71a2c2eed6397d5.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lg", "cpu": 8, @@ -4422,7 +4422,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f44ae34ac45346e7b929a05fd14f5e0c, md=sh-8b74d9b034554291a22155e99e40bb9c, lg=sh-96120366dd584ff7bbb2a400c21ad57d, lgx=sh-cc6a1f10e072403487294c10e8373ec5, xl=sh-d5a2a58974c945c78cb9e18e707ffd72, 2xl=sh-43af960b7d3a42aea71a2c2eed6397d5.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lgx", "cpu": 12, @@ -4451,7 +4451,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f44ae34ac45346e7b929a05fd14f5e0c, md=sh-8b74d9b034554291a22155e99e40bb9c, lg=sh-96120366dd584ff7bbb2a400c21ad57d, lgx=sh-cc6a1f10e072403487294c10e8373ec5, xl=sh-d5a2a58974c945c78cb9e18e707ffd72, 2xl=sh-43af960b7d3a42aea71a2c2eed6397d5.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "xl", "cpu": 16, @@ -4481,7 +4481,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-f44ae34ac45346e7b929a05fd14f5e0c, md=sh-8b74d9b034554291a22155e99e40bb9c, lg=sh-96120366dd584ff7bbb2a400c21ad57d, lgx=sh-cc6a1f10e072403487294c10e8373ec5, xl=sh-d5a2a58974c945c78cb9e18e707ffd72, 2xl=sh-43af960b7d3a42aea71a2c2eed6397d5.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "2xl", "cpu": 32, @@ -4511,7 +4511,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-2bc04e06cd6441608b5a158eb69cf2d2, md=sh-d1cae0713304442ab5bc646483a53aac, lg=sh-c3d8adfdfd5b41e88c91440edb778b57, lgx=sh-37dd83e3f91443d286cd1004780bdc69, xl=sh-3ae5a5ae36d643be94879cffe2e5e59c, 2xl=sh-5650a345553c47ebaf7e65154b9494d7.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "sm", "cpu": 2, @@ -4541,7 +4541,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-2bc04e06cd6441608b5a158eb69cf2d2, md=sh-d1cae0713304442ab5bc646483a53aac, lg=sh-c3d8adfdfd5b41e88c91440edb778b57, lgx=sh-37dd83e3f91443d286cd1004780bdc69, xl=sh-3ae5a5ae36d643be94879cffe2e5e59c, 2xl=sh-5650a345553c47ebaf7e65154b9494d7.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "md", "cpu": 4, @@ -4571,7 +4571,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-2bc04e06cd6441608b5a158eb69cf2d2, md=sh-d1cae0713304442ab5bc646483a53aac, lg=sh-c3d8adfdfd5b41e88c91440edb778b57, lgx=sh-37dd83e3f91443d286cd1004780bdc69, xl=sh-3ae5a5ae36d643be94879cffe2e5e59c, 2xl=sh-5650a345553c47ebaf7e65154b9494d7.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lg", "cpu": 8, @@ -4601,7 +4601,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-2bc04e06cd6441608b5a158eb69cf2d2, md=sh-d1cae0713304442ab5bc646483a53aac, lg=sh-c3d8adfdfd5b41e88c91440edb778b57, lgx=sh-37dd83e3f91443d286cd1004780bdc69, xl=sh-3ae5a5ae36d643be94879cffe2e5e59c, 2xl=sh-5650a345553c47ebaf7e65154b9494d7.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "lgx", "cpu": 12, @@ -4630,7 +4630,7 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-2bc04e06cd6441608b5a158eb69cf2d2, md=sh-d1cae0713304442ab5bc646483a53aac, lg=sh-c3d8adfdfd5b41e88c91440edb778b57, lgx=sh-37dd83e3f91443d286cd1004780bdc69, xl=sh-3ae5a5ae36d643be94879cffe2e5e59c, 2xl=sh-5650a345553c47ebaf7e65154b9494d7.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", - "defaultForKind": true, + "defaultForKind": false, "size": { "name": "xl", "cpu": 16, @@ -4660,6 +4660,425 @@ "notes": "cmux devbox epoch 2026-09-07-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 398a10fcf7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-08 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-2bc04e06cd6441608b5a158eb69cf2d2, md=sh-d1cae0713304442ab5bc646483a53aac, lg=sh-c3d8adfdfd5b41e88c91440edb778b57, lgx=sh-37dd83e3f91443d286cd1004780bdc69, xl=sh-3ae5a5ae36d643be94879cffe2e5e59c, 2xl=sh-5650a345553c47ebaf7e65154b9494d7.", "cmuxTuiCommit": "398a10fcf705ecbda08fdcf121d4049a99e29e79", "cmuxTuiSha256": "222567deaf105c6c8bc53f5a447cccdc056e19d6f584873d9eaba34e80ee390e", + "defaultForKind": false, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-base-sm", + "imageId": "sh-0d4c81c5188140dba93a17eaac1608e0", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "base", + "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + }, + "builtAt": "2026-09-10T03:08:36.028Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-0d4c81c5188140dba93a17eaac1608e0, md=sh-7e6e24521b4f4b00b7478313fddedd7e, lg=sh-e152ff6d015e45289a27104557374403, lgx=sh-edb83b4aa34a4d79946b5d80fedf8921, xl=sh-e27f9d8a81cc491080f6dd186d6f4808, 2xl=sh-a370d08c8f934d9aa1935613c3b28cd8.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + }, + "defaultForLocalDev": true + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-base-md", + "imageId": "sh-7e6e24521b4f4b00b7478313fddedd7e", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "base", + "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + }, + "builtAt": "2026-09-10T03:08:36.028Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-0d4c81c5188140dba93a17eaac1608e0, md=sh-7e6e24521b4f4b00b7478313fddedd7e, lg=sh-e152ff6d015e45289a27104557374403, lgx=sh-edb83b4aa34a4d79946b5d80fedf8921, xl=sh-e27f9d8a81cc491080f6dd186d6f4808, 2xl=sh-a370d08c8f934d9aa1935613c3b28cd8.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-base-lg", + "imageId": "sh-e152ff6d015e45289a27104557374403", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "base", + "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + }, + "builtAt": "2026-09-10T03:08:36.028Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-0d4c81c5188140dba93a17eaac1608e0, md=sh-7e6e24521b4f4b00b7478313fddedd7e, lg=sh-e152ff6d015e45289a27104557374403, lgx=sh-edb83b4aa34a4d79946b5d80fedf8921, xl=sh-e27f9d8a81cc491080f6dd186d6f4808, 2xl=sh-a370d08c8f934d9aa1935613c3b28cd8.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-base-lgx", + "imageId": "sh-edb83b4aa34a4d79946b5d80fedf8921", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "base", + "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + }, + "builtAt": "2026-09-10T03:08:36.028Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-0d4c81c5188140dba93a17eaac1608e0, md=sh-7e6e24521b4f4b00b7478313fddedd7e, lg=sh-e152ff6d015e45289a27104557374403, lgx=sh-edb83b4aa34a4d79946b5d80fedf8921, xl=sh-e27f9d8a81cc491080f6dd186d6f4808, 2xl=sh-a370d08c8f934d9aa1935613c3b28cd8.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-base-xl", + "imageId": "sh-e27f9d8a81cc491080f6dd186d6f4808", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "base", + "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + }, + "builtAt": "2026-09-10T03:08:36.028Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-0d4c81c5188140dba93a17eaac1608e0, md=sh-7e6e24521b4f4b00b7478313fddedd7e, lg=sh-e152ff6d015e45289a27104557374403, lgx=sh-edb83b4aa34a4d79946b5d80fedf8921, xl=sh-e27f9d8a81cc491080f6dd186d6f4808, 2xl=sh-a370d08c8f934d9aa1935613c3b28cd8.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-base-2xl", + "imageId": "sh-a370d08c8f934d9aa1935613c3b28cd8", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "base", + "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + }, + "builtAt": "2026-09-10T03:08:36.028Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-0d4c81c5188140dba93a17eaac1608e0, md=sh-7e6e24521b4f4b00b7478313fddedd7e, lg=sh-e152ff6d015e45289a27104557374403, lgx=sh-edb83b4aa34a4d79946b5d80fedf8921, xl=sh-e27f9d8a81cc491080f6dd186d6f4808, 2xl=sh-a370d08c8f934d9aa1935613c3b28cd8.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "2xl", + "cpu": 32, + "memoryMb": 65536, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-2xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-sm", + "imageId": "sh-69841eb8073046df92d2cca9cc8b4ba2", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "desktop", + "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + }, + "builtAt": "2026-09-10T03:09:29.041Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-69841eb8073046df92d2cca9cc8b4ba2, md=sh-fb1dba56f4b24616adfba52e6c935317, lg=sh-68da64518f6c49a1923788f503d3492a, lgx=sh-eec416ba245544a494d3be4ebe6022a2, xl=sh-6d635b1cd223434aa9e1ad1a8aea1c45, 2xl=sh-869c2c63c2cb4f399d19b5fdb1fee50f.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "sm", + "cpu": 2, + "memoryMb": 4096, + "storageMb": 16384, + "freestyleBase": "freestyle/ubuntu-sm" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-md", + "imageId": "sh-fb1dba56f4b24616adfba52e6c935317", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "desktop", + "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + }, + "builtAt": "2026-09-10T03:09:29.041Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-69841eb8073046df92d2cca9cc8b4ba2, md=sh-fb1dba56f4b24616adfba52e6c935317, lg=sh-68da64518f6c49a1923788f503d3492a, lgx=sh-eec416ba245544a494d3be4ebe6022a2, xl=sh-6d635b1cd223434aa9e1ad1a8aea1c45, 2xl=sh-869c2c63c2cb4f399d19b5fdb1fee50f.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "md", + "cpu": 4, + "memoryMb": 8192, + "storageMb": 32768, + "freestyleBase": "freestyle/ubuntu" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-lg", + "imageId": "sh-68da64518f6c49a1923788f503d3492a", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "desktop", + "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + }, + "builtAt": "2026-09-10T03:09:29.041Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-69841eb8073046df92d2cca9cc8b4ba2, md=sh-fb1dba56f4b24616adfba52e6c935317, lg=sh-68da64518f6c49a1923788f503d3492a, lgx=sh-eec416ba245544a494d3be4ebe6022a2, xl=sh-6d635b1cd223434aa9e1ad1a8aea1c45, 2xl=sh-869c2c63c2cb4f399d19b5fdb1fee50f.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "lg", + "cpu": 8, + "memoryMb": 16384, + "storageMb": 65536, + "freestyleBase": "freestyle/ubuntu-lg" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-lgx", + "imageId": "sh-eec416ba245544a494d3be4ebe6022a2", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "desktop", + "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + }, + "builtAt": "2026-09-10T03:09:29.041Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-69841eb8073046df92d2cca9cc8b4ba2, md=sh-fb1dba56f4b24616adfba52e6c935317, lg=sh-68da64518f6c49a1923788f503d3492a, lgx=sh-eec416ba245544a494d3be4ebe6022a2, xl=sh-6d635b1cd223434aa9e1ad1a8aea1c45, 2xl=sh-869c2c63c2cb4f399d19b5fdb1fee50f.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "lgx", + "cpu": 12, + "memoryMb": 24576, + "storageMb": 98304 + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-xl", + "imageId": "sh-6d635b1cd223434aa9e1ad1a8aea1c45", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "desktop", + "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + }, + "builtAt": "2026-09-10T03:09:29.041Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-69841eb8073046df92d2cca9cc8b4ba2, md=sh-fb1dba56f4b24616adfba52e6c935317, lg=sh-68da64518f6c49a1923788f503d3492a, lgx=sh-eec416ba245544a494d3be4ebe6022a2, xl=sh-6d635b1cd223434aa9e1ad1a8aea1c45, 2xl=sh-869c2c63c2cb4f399d19b5fdb1fee50f.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", + "defaultForKind": true, + "size": { + "name": "xl", + "cpu": 16, + "memoryMb": 32768, + "storageMb": 131072, + "freestyleBase": "freestyle/ubuntu-xl" + } + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-agents0909-2xl", + "imageId": "sh-869c2c63c2cb4f399d19b5fdb1fee50f", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "d3b2da01be1e84a9a8b8b0e9b5dd4edb8376daf7", + "epoch": "2026-09-09-r1", + "devboxSource": { + "layers": "desktop", + "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + }, + "builtAt": "2026-09-10T03:09:29.041Z", + "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.267", + "@openai/codex": "0.154.0", + "opencode-ai": "1.18.30", + "@earendil-works/pi-coding-agent": "0.85.1", + "agent-browser": "0.37.1" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-09-r1 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu-sm: the base's Node/Bun/Python/uv/Docker plus pinned agents, devtools, Chrome + cua-driver, ble.sh devshell, cmux login banner, and the desktop layer (openbox/TigerVNC 5901, noVNC 6901, Ghostty, Chrome, Thunar) run by the cmux-desktop systemd unit as ubuntu; ubuntu (uid 1000, NOPASSWD sudo) is the work user; hostname cmux (static, live, 127.0.1.1 alias; SSH host keys regenerated under it; journal reset); baked cmux-tui daemon 65ac4c2fe7, identity bound to the instance id, no create-time bootstrap. Validated 2026-09-10 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Sizes derived and re-booted by derive-devbox-sizes.ts: sm=sh-69841eb8073046df92d2cca9cc8b4ba2, md=sh-fb1dba56f4b24616adfba52e6c935317, lg=sh-68da64518f6c49a1923788f503d3492a, lgx=sh-eec416ba245544a494d3be4ebe6022a2, xl=sh-6d635b1cd223434aa9e1ad1a8aea1c45, 2xl=sh-869c2c63c2cb4f399d19b5fdb1fee50f.", + "cmuxTuiCommit": "65ac4c2fe7270e7b06b7e118df959ba793c3605b", + "cmuxTuiSha256": "157dc538fa0ffb7048ad385c8d0fc0feaa94d9de8b242c0d9860da40b20f2535", "defaultForKind": true, "size": { "name": "2xl", From c9af1303bceea3008cac3be5176d9e3e358298bc Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 9 Sep 2026 21:14:58 -0700 Subject: [PATCH 04/18] cloud: source digest schema 2 covers the bake script and the Dockerfile instructions Review finding (CodeRabbit on #12250): the drift digest hashed the verbatim files, the ARG pins and the epoch, so a step change with no ARG behind it (this PR's bubblewrap apt line, in both recipes) left it unchanged and only the epoch bump carried it. Schema 2 adds sha256s of the Dockerfile reduced to its instructions and of build-devbox-freestyle.ts reduced to its code lines (comment and blank lines dropped; no tokenizer, byte-stable). An entry is checked with the schema it was recorded with (absent: 1), so the ladders promoted by this PR stay valid and new bakes record schema 2. Co-Authored-By: Claude Fable 5.1 --- web/scripts/devbox-image-common.ts | 102 +++++++++++++++++++---- web/services/vms/images/devbox/README.md | 12 ++- web/tests/vm-devbox-image.test.ts | 26 +++++- web/tests/vm-image-manifest.test.ts | 15 +++- 4 files changed, 130 insertions(+), 25 deletions(-) diff --git a/web/scripts/devbox-image-common.ts b/web/scripts/devbox-image-common.ts index 1ea5810a9b3..696c84c570d 100644 --- a/web/scripts/devbox-image-common.ts +++ b/web/scripts/devbox-image-common.ts @@ -333,23 +333,77 @@ export function devboxImageEpoch(dockerfile = readDevboxDockerfile()): string { return /CMUX_IMAGE_EPOCH=([^\s"]+)/.exec(dockerfile)?.[1] ?? "none"; } +/** + * The source-digest formula version a manifest entry was recorded with. + * Schema 1 covered the verbatim files, the ARG pins and the epoch; schema 2 + * adds the Dockerfile's instructions and the Freestyle bake script (both with + * comment and blank lines dropped), so a step change such as a new apt + * package can no longer leave the digest unchanged. Entries keep the schema + * they were recorded with and are checked with that formula, so a formula + * change never forces a rebake of an already promoted ladder; new bakes + * record the current schema. + */ +export const DEVBOX_SOURCE_SCHEMA = 2; +export const bakeScriptPath = path.join(webRoot, "scripts/build-devbox-freestyle.ts"); + +/** + * A Dockerfile reduced to its instructions: comment lines and blank lines + * dropped, trailing whitespace trimmed. The container recipe and the Freestyle + * replay are kept in step by hand, so an instruction edit is an image change + * even when no ARG moved; a comment is not. + */ +export function normalizedDockerfileInstructions(dockerfile: string): string { + return dockerfile + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => line.trim() !== "" && !line.trim().startsWith("#")) + .join("\n"); +} + +/** + * A TypeScript source reduced to its code lines: whole-line comments (lines + * beginning with two slashes, a slash-star opener, or a star inside a doc + * block) and blank lines dropped, trailing whitespace trimmed. Trailing + * comments after code stay, as do comment markers inside strings: no + * tokenizer, so the result is byte-stable across runtimes. The prose in the + * bake script cannot change a machine; every step it runs can. + */ +export function normalizedBakeScript(source: string): string { + return source + .split("\n") + .map((line) => line.trimEnd()) + .filter((line) => { + const trimmed = line.trim(); + return trimmed !== "" && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*"); + }) + .join("\n"); +} + /** * Everything the Freestyle bake takes from this checkout for an image with * `layers` (the shell layer for `base`, plus the desktop layer for * `desktop`): the files shipped verbatim (sha256 each, the desktop files by * their DEVBOX_DESKTOP_INSTALLS path), the pins the Dockerfile ARGs carry - * (agents, cua-driver, the Ghostty .deb, the desktop apt list) and the - * epoch. Dockerfile prose is deliberately not part of it: a comment cannot - * change a machine. `devboxSourceDigest` is its sha256, recorded on every - * manifest entry at bake time so `devboxSourceDriftProblems` can tell when - * main describes a machine the promoted default no longer is. + * (agents, cua-driver, the Ghostty .deb, the desktop apt list), the epoch + * and, from schema 2, the Dockerfile's instructions and the bake script's + * code (normalizedDockerfileInstructions, normalizedBakeScript). Prose is + * deliberately not part of it: a comment cannot change a machine. + * `devboxSourceDigest` is its sha256, recorded on every manifest entry at + * bake time so `devboxSourceDriftProblems` can tell when main describes a + * machine the promoted default no longer is. */ -export function devboxSourceManifest(layers: DevboxImageKind, dockerfile = readDevboxDockerfile()): Record { +export function devboxSourceManifest( + layers: DevboxImageKind, + dockerfile = readDevboxDockerfile(), + schema: number = DEVBOX_SOURCE_SCHEMA, + bakeScript = () => readFileSync(bakeScriptPath, "utf8"), +): Record { + if (schema !== 1 && schema !== 2) throw new Error(`unknown devbox source schema ${schema}`); const files = Object.fromEntries( DEVBOX_TEMPLATE_FILES.filter((name) => name !== "Dockerfile").map((name) => [name, sha256File(path.join(devboxDir, name))]), ); - const shell = { - schema: 1, + const shell: Record = { + schema, layers, epoch: devboxImageEpoch(dockerfile), agentPins: Object.fromEntries(devboxAgentPins(dockerfile).map((pin) => [pin.pkg, pin.version])), @@ -357,6 +411,10 @@ export function devboxSourceManifest(layers: DevboxImageKind, dockerfile = readD ghosttyVersion: devboxGhosttyVersion(dockerfile), files, }; + if (schema >= 2) { + shell.dockerfileInstructions = createHash("sha256").update(normalizedDockerfileInstructions(dockerfile)).digest("hex"); + shell.bakeScript = createHash("sha256").update(normalizedBakeScript(bakeScript())).digest("hex"); + } if (layers === "base") return shell; return { ...shell, @@ -368,14 +426,20 @@ export function devboxSourceManifest(layers: DevboxImageKind, dockerfile = readD }; } -export function devboxSourceDigest(layers: DevboxImageKind, dockerfile = readDevboxDockerfile()): string { - return createHash("sha256").update(JSON.stringify(devboxSourceManifest(layers, dockerfile))).digest("hex"); +export function devboxSourceDigest( + layers: DevboxImageKind, + dockerfile = readDevboxDockerfile(), + schema: number = DEVBOX_SOURCE_SCHEMA, + bakeScript?: () => string, +): string { + return createHash("sha256").update(JSON.stringify(devboxSourceManifest(layers, dockerfile, schema, bakeScript))).digest("hex"); } -/** Which layers an image carries and the digest of the sources they were baked from. */ +/** Which layers an image carries, the digest of the sources they were baked from, and the formula it was computed with (absent: schema 1). */ export type DevboxSourceRecord = { readonly layers: DevboxImageKind; readonly digest: string; + readonly schema?: number; }; export function devboxTemplateFile(name: string): string { @@ -618,7 +682,7 @@ export function bakeMetadata( agentToolResolvedVersions: Object.fromEntries( devboxAgentPins().map((pin) => [pin.pkg, pin.version]), ), - devboxSource: { layers, digest: devboxSourceDigest(layers) }, + devboxSource: { layers, digest: devboxSourceDigest(layers), schema: DEVBOX_SOURCE_SCHEMA }, }; } @@ -1019,7 +1083,7 @@ export function devboxSourceDriftProblems( ): string[] { const problems: string[] = []; const epoch = devboxImageEpoch(dockerfile); - const digests = new Map(); + const digests = new Map(); for (const entry of manifest.images) { if (entry.provider !== provider || !entry.defaultForKind) continue; const bakedEpoch = manifestEntryEpoch(entry); @@ -1032,10 +1096,16 @@ export function devboxSourceDriftProblems( problems.push(`${entry.version}: devboxSource.layers ${String(source.layers)} is not desktop|base`); continue; } - const current = digests.get(source.layers) ?? devboxSourceDigest(source.layers, dockerfile); - digests.set(source.layers, current); + const schema = source.schema ?? 1; + if (schema !== 1 && schema !== 2) { + problems.push(`${entry.version}: devboxSource.schema ${String(source.schema)} is not a known formula (1 or 2)`); + continue; + } + const key = `${source.layers}/${schema}`; + const current = digests.get(key) ?? devboxSourceDigest(source.layers, dockerfile, schema); + digests.set(key, current); if (source.digest !== current) { - problems.push(`${entry.version}: baked from devbox sources ${source.digest.slice(0, 12)}…, this checkout's ${source.layers} sources are ${current.slice(0, 12)}…; promote a new bake or revert the sources with the manifest`); + problems.push(`${entry.version}: baked from devbox sources ${source.digest.slice(0, 12)}… (schema ${schema}), this checkout's ${source.layers} sources are ${current.slice(0, 12)}…; promote a new bake or revert the sources with the manifest`); } } return problems; diff --git a/web/services/vms/images/devbox/README.md b/web/services/vms/images/devbox/README.md index 6229ff2616f..203f7d2bdb6 100644 --- a/web/services/vms/images/devbox/README.md +++ b/web/services/vms/images/devbox/README.md @@ -58,11 +58,15 @@ writes): `CMUX_IMAGE_EPOCH` (the entry's `epoch`, or the `cmux devbox epoch` prefix of its `notes` on older entries), so an epoch bump lands together with its promotion and a rollback to an older ladder also reverts the sources; -- an entry that recorded `devboxSource` (`{ layers, digest }`, +- an entry that recorded `devboxSource` (`{ layers, digest, schema }`, `devboxSourceDigest()`: sha256 over the files the bake ships verbatim, the - agent, cua-driver and Ghostty pins, the desktop apt list and the epoch, - per layer set; Dockerfile prose is excluded because a comment cannot change - a machine) was baked from exactly this checkout's sources. + agent, cua-driver and Ghostty pins, the desktop apt list, the epoch and, + from schema 2, the Dockerfile's instructions and the bake script's code + with comment and blank lines dropped, per layer set; prose is excluded + because a comment cannot change a machine) was baked from exactly this + checkout's sources. An entry is checked with the schema it was recorded + with (absent: 1), so a formula change never forces a rebake; new bakes + record `DEVBOX_SOURCE_SCHEMA`. Rollback is therefore a revert of the promotion commit as a whole, never the manifest flags alone. diff --git a/web/tests/vm-devbox-image.test.ts b/web/tests/vm-devbox-image.test.ts index 345dcd4cbe1..86567180064 100644 --- a/web/tests/vm-devbox-image.test.ts +++ b/web/tests/vm-devbox-image.test.ts @@ -12,6 +12,7 @@ import { } from "../services/vms/drivers/cmuxTuiDaemon"; import { AGENT_PIN_ARGS, + DEVBOX_SOURCE_SCHEMA, DEVBOX_TEMPLATE_FILES, agentPinDrift, devboxAgentPins, @@ -20,6 +21,8 @@ import { devboxParkDaemonCommand, devboxSourceDigest, devboxSourceManifest, + normalizedBakeScript, + normalizedDockerfileInstructions, rewriteDevboxAgentPins, } from "../scripts/devbox-image-common"; @@ -411,7 +414,10 @@ describe("devbox image template", () => { const desktop = devboxSourceManifest("desktop", dockerfile); // Pins, epoch and the verbatim files: a pin bump, an epoch bump, or a // template edit each changes the digest; Dockerfile prose does not. - expect(base).toMatchObject({ schema: 1, layers: "base", agentPins: Object.fromEntries(devboxAgentPins(dockerfile).map((pin) => [pin.pkg, pin.version])) }); + expect(DEVBOX_SOURCE_SCHEMA).toBe(2); + expect(base).toMatchObject({ schema: 2, layers: "base", agentPins: Object.fromEntries(devboxAgentPins(dockerfile).map((pin) => [pin.pkg, pin.version])) }); + expect(typeof base.dockerfileInstructions).toBe("string"); + expect(typeof base.bakeScript).toBe("string"); expect(Object.keys(base.files as Record).sort()).toEqual([...DEVBOX_TEMPLATE_FILES].filter((name) => name !== "Dockerfile").sort()); expect(base).not.toHaveProperty("desktopFiles"); expect(desktop).toHaveProperty("desktopFiles"); @@ -421,6 +427,24 @@ describe("devbox image template", () => { const codex = devboxAgentPins(dockerfile).find((pin) => pin.pkg === "@openai/codex")!; expect(devboxSourceDigest("base", rewriteDevboxAgentPins(dockerfile, { "@openai/codex": `${codex.version}9` }))).not.toBe(devboxSourceDigest("base", dockerfile)); expect(devboxSourceDigest("base", dockerfile.replace(/^ENV CMUX_IMAGE_EPOCH=.*$/m, "ENV CMUX_IMAGE_EPOCH=1999-01-01-r1"))).not.toBe(devboxSourceDigest("base", dockerfile)); + // Schema 2 also sees a Dockerfile instruction change (a package added to a + // RUN, no ARG moved) and a bake-script code change; comment lines in + // either move nothing. Schema 1, the formula the first promoted ladders + // were recorded with, still ignores both. + const withStep = dockerfile.replace(/^ bubblewrap \\$/m, " bubblewrap \\\n cowsay \\"); + expect(withStep).not.toBe(dockerfile); + expect(devboxSourceDigest("base", withStep)).not.toBe(devboxSourceDigest("base", dockerfile)); + expect(devboxSourceDigest("base", withStep, 1)).toBe(devboxSourceDigest("base", dockerfile, 1)); + const bake = readScript("build-devbox-freestyle.ts"); + const withCode = () => `${bake}\nconsole.log("one more step");\n`; + const withComment = () => `${bake}\n// a comment changes no machine\n/**\n * neither does a doc block\n */\n`; + expect(devboxSourceDigest("base", dockerfile, 2, withCode)).not.toBe(devboxSourceDigest("base", dockerfile, 2, () => bake)); + expect(devboxSourceDigest("base", dockerfile, 2, withComment)).toBe(devboxSourceDigest("base", dockerfile, 2, () => bake)); + expect(devboxSourceDigest("base", dockerfile, 1, withCode)).toBe(devboxSourceDigest("base", dockerfile, 1, () => bake)); + expect(() => devboxSourceDigest("base", dockerfile, 3)).toThrow(/unknown devbox source schema/); + // The normalizers themselves: comment and blank lines gone, code kept verbatim (trailing space trimmed). + expect(normalizedDockerfileInstructions("# c\n\nFROM ubuntu:24.04 \nRUN echo hi # keep\n # indented comment\n")).toBe("FROM ubuntu:24.04\nRUN echo hi # keep"); + expect(normalizedBakeScript("// c\n/**\n * doc\n */\nconst a = 1; // trailing stays\n\n const url = \"https://x\";\n")).toBe("const a = 1; // trailing stays\n const url = \"https://x\";"); // Both bake entry points record the digest for the layers they baked. expect(readScript("build-devbox-freestyle.ts")).toContain('bakeMetadata(preflight, fileURLToPath(import.meta.url), withDesktop ? "desktop" : "base")'); expect(readScript("promote-devbox-image.ts")).toContain("devboxSourceDriftProblems({ ...next, images: added })"); diff --git a/web/tests/vm-image-manifest.test.ts b/web/tests/vm-image-manifest.test.ts index e0cbc222d99..57352f728df 100644 --- a/web/tests/vm-image-manifest.test.ts +++ b/web/tests/vm-image-manifest.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; import { + DEVBOX_SOURCE_SCHEMA, appendImageManifestEntries, bakeMetadata, devboxImageEpoch, @@ -76,7 +77,7 @@ describe("devboxSourceDriftProblems", () => { kind: layers, defaultForKind: true, epoch, - devboxSource: { layers, digest: devboxSourceDigest(layers, dockerfile) }, + devboxSource: { layers, digest: devboxSourceDigest(layers, dockerfile), schema: DEVBOX_SOURCE_SCHEMA }, notes: `cmux devbox epoch ${epoch}`, ...overrides, }); @@ -114,9 +115,15 @@ describe("devboxSourceDriftProblems", () => { expect(drifted).toHaveLength(1); expect(drifted[0]).toContain("baked from devbox sources 000000000000"); // A desktop image promoted as the base kind is held to the desktop sources it was baked from. - expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "desktop", digest: devboxSourceDigest("desktop", dockerfile) } })))).toEqual([]); - expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "desktop", digest: devboxSourceDigest("base", dockerfile) } })))).toHaveLength(1); + expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "desktop", digest: devboxSourceDigest("desktop", dockerfile), schema: DEVBOX_SOURCE_SCHEMA } })))).toEqual([]); + expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "desktop", digest: devboxSourceDigest("base", dockerfile), schema: DEVBOX_SOURCE_SCHEMA } })))).toHaveLength(1); expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "vnc" as "base", digest: "x" } })))[0]).toContain("is not desktop|base"); + // An entry keeps the formula it was recorded with: a schema-1 record (no + // `schema` field, the first promoted ladders) is checked with schema 1 and + // stays valid across a formula change; an unknown schema is refused. + expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "base", digest: devboxSourceDigest("base", dockerfile, 1) } })))).toEqual([]); + expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "base", digest: devboxSourceDigest("base", dockerfile, 1), schema: 2 } })))).toHaveLength(1); + expect(devboxSourceDriftProblems(manifestOf(current("base", { devboxSource: { layers: "base", digest: "x", schema: 9 } })))[0]).toContain("is not a known formula"); // A Dockerfile change is what makes the checkout drift from the default. const bumped = dockerfile.replace(/^ENV CMUX_IMAGE_EPOCH=.*$/m, "ENV CMUX_IMAGE_EPOCH=2099-01-01-r1"); const problems = devboxSourceDriftProblems(manifestOf(current("desktop")), "freestyle", bumped); @@ -126,7 +133,7 @@ describe("devboxSourceDriftProblems", () => { test("the bake records the epoch and the source digest, and promotion carries them onto every variant", () => { const metadata = bakeMetadata({ sha: "abc123", epoch }, path.join(import.meta.dirname, "../scripts/build-devbox-freestyle.ts"), "desktop"); - expect(metadata.devboxSource).toEqual({ layers: "desktop", digest: devboxSourceDigest("desktop", dockerfile) }); + expect(metadata.devboxSource).toEqual({ layers: "desktop", digest: devboxSourceDigest("desktop", dockerfile), schema: DEVBOX_SOURCE_SCHEMA }); const entry = manifestEntrySkeleton("freestyle", "freestyle-x", "sh-x", "FREESTYLE_SANDBOX_SNAPSHOT", metadata, "", "desktop"); expect(entry).toMatchObject({ epoch, devboxSource: metadata.devboxSource, validationStatus: "unknown" }); const promoted = promoteImageManifestEntry(manifestOf(), { ...entry, validationStatus: "passed" }, { From c690d875b0e98b140c7ec6961e301d7657f0a06c Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 9 Sep 2026 21:29:26 -0700 Subject: [PATCH 05/18] cloud: source digest hashes every bake-script line; upgrade the promoted entries to schema 2 provably Review findings (CodeRabbit on #12250, second pass): - The bake-script normalizer dropped any `*`-prefixed line as a doc block, so a change limited to a continued multiplication or a generator method would not move the digest. Without a full TypeScript lexer no line heuristic is safe, so schema 2 now hashes every non-blank line of build-devbox-freestyle.ts (trailing whitespace trimmed). A comment edit there now asks for a re-promotion; a digest that cannot miss a step change is worth that. The Dockerfile keeps its grammar-correct comment drop and now keeps parser directives. Regression tests cover the `*` line, the comment line, and the directive. - The 12 agents0909 defaults were recorded at schema 1. `promote --upgrade-source-schema` (upgradeDevboxSourceRecords) moves a default up only when its schema-1 digest equals this checkout's and its recorded builderScriptVersion equals this checkout's bake script, which is exactly what proves the newer formula's extra input; anything else is kept and reported. All 12 qualified (the bake script is unchanged since the bake). Co-Authored-By: Claude Fable 5.1 --- web/scripts/devbox-image-common.ts | 117 +++++++++++++++++------ web/scripts/promote-devbox-image.ts | 29 +++++- web/services/vms/images/devbox/README.md | 5 +- web/services/vms/images/manifest.json | 36 ++++--- web/tests/vm-devbox-image.test.ts | 27 ++++-- web/tests/vm-image-manifest.test.ts | 50 ++++++++++ 6 files changed, 215 insertions(+), 49 deletions(-) diff --git a/web/scripts/devbox-image-common.ts b/web/scripts/devbox-image-common.ts index 696c84c570d..3d68280c6a4 100644 --- a/web/scripts/devbox-image-common.ts +++ b/web/scripts/devbox-image-common.ts @@ -336,46 +336,60 @@ export function devboxImageEpoch(dockerfile = readDevboxDockerfile()): string { /** * The source-digest formula version a manifest entry was recorded with. * Schema 1 covered the verbatim files, the ARG pins and the epoch; schema 2 - * adds the Dockerfile's instructions and the Freestyle bake script (both with - * comment and blank lines dropped), so a step change such as a new apt - * package can no longer leave the digest unchanged. Entries keep the schema - * they were recorded with and are checked with that formula, so a formula - * change never forces a rebake of an already promoted ladder; new bakes - * record the current schema. + * adds the Dockerfile's instructions (comments dropped by the Dockerfile + * grammar) and every non-blank line of the Freestyle bake script, so a step + * change such as a new apt package can no longer leave the digest unchanged. + * Entries keep the schema they were recorded with and are checked with that + * formula, so a formula change never forces a rebake of an already promoted + * ladder; new bakes record the current schema, and + * `upgradeDevboxSourceRecords` moves an entry up only when its provenance is + * proven from what it already recorded. */ export const DEVBOX_SOURCE_SCHEMA = 2; export const bakeScriptPath = path.join(webRoot, "scripts/build-devbox-freestyle.ts"); /** - * A Dockerfile reduced to its instructions: comment lines and blank lines - * dropped, trailing whitespace trimmed. The container recipe and the Freestyle - * replay are kept in step by hand, so an instruction edit is an image change - * even when no ARG moved; a comment is not. + * A Dockerfile reduced to its instructions, by the Dockerfile grammar: a line + * whose first non-blank character is `#` is a comment (also inside a + * continued instruction) unless it is a parser directive (`# key=value` + * before the first instruction), which changes how the file is read and is + * kept. Blank lines dropped, trailing whitespace trimmed. The container + * recipe and the Freestyle replay are kept in step by hand, so an + * instruction edit is an image change even when no ARG moved; a comment is + * not. */ export function normalizedDockerfileInstructions(dockerfile: string): string { - return dockerfile - .split("\n") - .map((line) => line.trimEnd()) - .filter((line) => line.trim() !== "" && !line.trim().startsWith("#")) - .join("\n"); + const kept: string[] = []; + let beforeFirstInstruction = true; + for (const raw of dockerfile.split("\n")) { + const line = raw.trimEnd(); + const trimmed = line.trim(); + if (trimmed === "") continue; + if (trimmed.startsWith("#")) { + if (beforeFirstInstruction && /^#\s*[A-Za-z][A-Za-z0-9]*\s*=/.test(trimmed)) kept.push(line); + continue; + } + beforeFirstInstruction = false; + kept.push(line); + } + return kept.join("\n"); } /** - * A TypeScript source reduced to its code lines: whole-line comments (lines - * beginning with two slashes, a slash-star opener, or a star inside a doc - * block) and blank lines dropped, trailing whitespace trimmed. Trailing - * comments after code stay, as do comment markers inside strings: no - * tokenizer, so the result is byte-stable across runtimes. The prose in the - * bake script cannot change a machine; every step it runs can. + * The bake script reduced to its non-blank lines, trailing whitespace + * trimmed. Nothing else is dropped on purpose: telling a comment from code + * in TypeScript needs a full lexer (block comments, strings, template + * literals, regex literals), and any line heuristic can hide a code change + * (a `*`-prefixed continuation, a generator method). So a comment edit to + * build-devbox-freestyle.ts also moves the digest and asks for a + * re-promotion; that is the price of a digest that cannot miss a step + * change, and edits to the bake script are promoted anyway. */ export function normalizedBakeScript(source: string): string { return source .split("\n") .map((line) => line.trimEnd()) - .filter((line) => { - const trimmed = line.trim(); - return trimmed !== "" && !trimmed.startsWith("//") && !trimmed.startsWith("/*") && !trimmed.startsWith("*"); - }) + .filter((line) => line.trim() !== "") .join("\n"); } @@ -386,8 +400,10 @@ export function normalizedBakeScript(source: string): string { * their DEVBOX_DESKTOP_INSTALLS path), the pins the Dockerfile ARGs carry * (agents, cua-driver, the Ghostty .deb, the desktop apt list), the epoch * and, from schema 2, the Dockerfile's instructions and the bake script's - * code (normalizedDockerfileInstructions, normalizedBakeScript). Prose is - * deliberately not part of it: a comment cannot change a machine. + * non-blank lines (normalizedDockerfileInstructions, normalizedBakeScript). + * Dockerfile prose is deliberately not part of it: a comment cannot change a + * machine; bake-script prose is, for want of a lexer that could tell it from + * code. * `devboxSourceDigest` is its sha256, recorded on every manifest entry at * bake time so `devboxSourceDriftProblems` can tell when main describes a * machine the promoted default no longer is. @@ -1060,6 +1076,53 @@ export function devboxImageLadderProblems( } +/** + * Moves default entries recorded at an older source schema to the current + * one without a rebake, only where the provenance is already proven by what + * the entry recorded: its digest at its own schema must equal this checkout's + * (the verbatim files, pins and epoch are the bake's), and its + * `builderScriptVersion` must equal this checkout's bake script (the one + * input the newer schema adds that the older one did not cover; the + * Dockerfile instructions are covered by the same ARG-derived pins plus the + * fact that the Freestyle replay reads nothing else from it). Anything else + * is left alone and reported: a rebake is the only other way up. Pure. + */ +export function upgradeDevboxSourceRecords( + manifest: DevboxImageManifest, + options: { provider?: DevboxProvider; dockerfile?: string; bakeScript?: () => string } = {}, +): { manifest: DevboxImageManifest; upgraded: string[]; skipped: Array<{ version: string; reason: string }> } { + const provider = options.provider ?? "freestyle"; + const dockerfile = options.dockerfile ?? readDevboxDockerfile(); + const bakeScript = options.bakeScript ?? (() => readFileSync(bakeScriptPath, "utf8")); + const bakeScriptSha256 = createHash("sha256").update(bakeScript()).digest("hex"); + const upgraded: string[] = []; + const skipped: Array<{ version: string; reason: string }> = []; + const images = manifest.images.map((entry) => { + const source = entry.devboxSource; + if (entry.provider !== provider || !entry.defaultForKind || !source) return entry; + const schema = source.schema ?? 1; + if (schema >= DEVBOX_SOURCE_SCHEMA) return entry; + if (source.layers !== "desktop" && source.layers !== "base") { + skipped.push({ version: entry.version, reason: `devboxSource.layers ${String(source.layers)} is not desktop|base` }); + return entry; + } + if (source.digest !== devboxSourceDigest(source.layers, dockerfile, schema, bakeScript)) { + skipped.push({ version: entry.version, reason: `schema ${schema} digest does not match this checkout` }); + return entry; + } + if (entry.builderScriptVersion !== bakeScriptSha256) { + skipped.push({ version: entry.version, reason: "builderScriptVersion does not match this checkout's bake script" }); + return entry; + } + upgraded.push(entry.version); + return { + ...entry, + devboxSource: { layers: source.layers, digest: devboxSourceDigest(source.layers, dockerfile, DEVBOX_SOURCE_SCHEMA, bakeScript), schema: DEVBOX_SOURCE_SCHEMA }, + }; + }); + return { manifest: { schemaVersion: manifest.schemaVersion, images }, upgraded, skipped }; +} + /** The epoch an entry was baked at: the field, or the `cmux devbox epoch ` prefix every bake writes into `notes`. */ export function manifestEntryEpoch(entry: Pick): string | undefined { return entry.epoch ?? /cmux devbox epoch (\S+)/.exec(entry.notes ?? "")?.[1]; diff --git a/web/scripts/promote-devbox-image.ts b/web/scripts/promote-devbox-image.ts index 1cef843f478..5dd8883dfc9 100644 --- a/web/scripts/promote-devbox-image.ts +++ b/web/scripts/promote-devbox-image.ts @@ -40,6 +40,10 @@ * --skip-verify record validationStatus "unknown" instead of verifying. * The entry is appended but NOT flagged as any default. * --dry-run print the manifest diff without writing it. + * --upgrade-source-schema move default entries recorded at an older source + * digest schema to the current one, only where their recorded + * digest and builderScriptVersion prove the checkout is what + * they were baked from (upgradeDevboxSourceRecords); no bake. * --replay re-apply the rows an earlier promotion appended (the * `entries` of its --out summary, or those rows copied from * that PR's manifest diff) onto the current manifest: no bake, @@ -81,6 +85,7 @@ import { type DevboxImageManifest, type DevboxManifestEntry, type DevboxProvider, + upgradeDevboxSourceRecords, } from "./devbox-image-common"; const provider = process.argv[2] as DevboxProvider | undefined; @@ -149,7 +154,29 @@ function commitManifest(label: string, manifest: DevboxImageManifest, next: Devb return added; } -// 0. Replay (see the header): only the manifest edit, from rows that already +// 0a. Source-schema upgrade (see the header): a manifest edit that adds no +// row; every change is proven from what the entries already recorded. +if (hasFlag("--upgrade-source-schema")) { + const manifest = readImageManifest(); + const result = upgradeDevboxSourceRecords(manifest, { provider }); + for (const row of result.skipped) console.log(`kept: ${row.version} (${row.reason})`); + if (result.upgraded.length === 0) { + console.log("no default entry to upgrade"); + process.exit(0); + } + const problems = [...imageManifestProblems(result.manifest), ...devboxSourceDriftProblems(result.manifest)]; + if (problems.length > 0) throw new Error(`refusing to write an inconsistent manifest:\n ${problems.join("\n ")}`); + console.log(`upgraded to source schema ${result.upgraded.length} entries:\n ${result.upgraded.join("\n ")}`); + if (dryRun) { + console.log(`--dry-run: not writing ${imageManifestPath}`); + } else { + writeImageManifest(result.manifest); + console.log(`wrote ${imageManifestPath}`); + } + process.exit(0); +} + +// 0b. Replay (see the header): only the manifest edit, from rows that already // carry their verify outcome and derived ids. const replayPath = argValue("--replay"); if (replayPath) { diff --git a/web/services/vms/images/devbox/README.md b/web/services/vms/images/devbox/README.md index 203f7d2bdb6..f713fadaa99 100644 --- a/web/services/vms/images/devbox/README.md +++ b/web/services/vms/images/devbox/README.md @@ -66,7 +66,10 @@ writes): because a comment cannot change a machine) was baked from exactly this checkout's sources. An entry is checked with the schema it was recorded with (absent: 1), so a formula change never forces a rebake; new bakes - record `DEVBOX_SOURCE_SCHEMA`. + record `DEVBOX_SOURCE_SCHEMA`, and `bun run devbox:promote -- freestyle + --upgrade-source-schema` moves older defaults up without a bake where + their recorded digest and `builderScriptVersion` prove the checkout is + what they were baked from (anything else is kept and reported). Rollback is therefore a revert of the promotion commit as a whole, never the manifest flags alone. diff --git a/web/services/vms/images/manifest.json b/web/services/vms/images/manifest.json index 820fd4d36dd..6c96800976f 100644 --- a/web/services/vms/images/manifest.json +++ b/web/services/vms/images/manifest.json @@ -5039,7 +5039,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "base", - "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + "digest": "26bb25a193684ef9042bd358c42854142d29ef721a75ec3f32f7a747b1a8cda2", + "schema": 2 }, "builtAt": "2026-09-10T03:08:36.028Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5075,7 +5076,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "base", - "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + "digest": "26bb25a193684ef9042bd358c42854142d29ef721a75ec3f32f7a747b1a8cda2", + "schema": 2 }, "builtAt": "2026-09-10T03:08:36.028Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5110,7 +5112,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "base", - "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + "digest": "26bb25a193684ef9042bd358c42854142d29ef721a75ec3f32f7a747b1a8cda2", + "schema": 2 }, "builtAt": "2026-09-10T03:08:36.028Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5145,7 +5148,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "base", - "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + "digest": "26bb25a193684ef9042bd358c42854142d29ef721a75ec3f32f7a747b1a8cda2", + "schema": 2 }, "builtAt": "2026-09-10T03:08:36.028Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5179,7 +5183,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "base", - "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + "digest": "26bb25a193684ef9042bd358c42854142d29ef721a75ec3f32f7a747b1a8cda2", + "schema": 2 }, "builtAt": "2026-09-10T03:08:36.028Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5214,7 +5219,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "base", - "digest": "15bed166e4f3c70794161bc175bfc96730df7d6ee6c047039e9b05e6e289356a" + "digest": "26bb25a193684ef9042bd358c42854142d29ef721a75ec3f32f7a747b1a8cda2", + "schema": 2 }, "builtAt": "2026-09-10T03:08:36.028Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5249,7 +5255,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "desktop", - "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + "digest": "499d3f102e2b24663788c43050049defbe280c87d6dcb9fd9544fb55afe71b5a", + "schema": 2 }, "builtAt": "2026-09-10T03:09:29.041Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5284,7 +5291,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "desktop", - "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + "digest": "499d3f102e2b24663788c43050049defbe280c87d6dcb9fd9544fb55afe71b5a", + "schema": 2 }, "builtAt": "2026-09-10T03:09:29.041Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5319,7 +5327,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "desktop", - "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + "digest": "499d3f102e2b24663788c43050049defbe280c87d6dcb9fd9544fb55afe71b5a", + "schema": 2 }, "builtAt": "2026-09-10T03:09:29.041Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5354,7 +5363,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "desktop", - "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + "digest": "499d3f102e2b24663788c43050049defbe280c87d6dcb9fd9544fb55afe71b5a", + "schema": 2 }, "builtAt": "2026-09-10T03:09:29.041Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5388,7 +5398,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "desktop", - "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + "digest": "499d3f102e2b24663788c43050049defbe280c87d6dcb9fd9544fb55afe71b5a", + "schema": 2 }, "builtAt": "2026-09-10T03:09:29.041Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", @@ -5423,7 +5434,8 @@ "epoch": "2026-09-09-r1", "devboxSource": { "layers": "desktop", - "digest": "97f5ac5c26c48de5aa1202be66aed3952c7a87951f8108949b7562fc529dcb42" + "digest": "499d3f102e2b24663788c43050049defbe280c87d6dcb9fd9544fb55afe71b5a", + "schema": 2 }, "builtAt": "2026-09-10T03:09:29.041Z", "builderScriptVersion": "716897e9b3988f8eebe4176da927799363f662ba5f745c17b5611d366f8e7ee2", diff --git a/web/tests/vm-devbox-image.test.ts b/web/tests/vm-devbox-image.test.ts index 86567180064..05550de2976 100644 --- a/web/tests/vm-devbox-image.test.ts +++ b/web/tests/vm-devbox-image.test.ts @@ -428,23 +428,34 @@ describe("devbox image template", () => { expect(devboxSourceDigest("base", rewriteDevboxAgentPins(dockerfile, { "@openai/codex": `${codex.version}9` }))).not.toBe(devboxSourceDigest("base", dockerfile)); expect(devboxSourceDigest("base", dockerfile.replace(/^ENV CMUX_IMAGE_EPOCH=.*$/m, "ENV CMUX_IMAGE_EPOCH=1999-01-01-r1"))).not.toBe(devboxSourceDigest("base", dockerfile)); // Schema 2 also sees a Dockerfile instruction change (a package added to a - // RUN, no ARG moved) and a bake-script code change; comment lines in - // either move nothing. Schema 1, the formula the first promoted ladders - // were recorded with, still ignores both. + // RUN, no ARG moved) and any non-blank line change in the bake script; a + // Dockerfile comment moves nothing. Schema 1, the formula the first + // promoted ladders were recorded with, ignores both. const withStep = dockerfile.replace(/^ bubblewrap \\$/m, " bubblewrap \\\n cowsay \\"); expect(withStep).not.toBe(dockerfile); expect(devboxSourceDigest("base", withStep)).not.toBe(devboxSourceDigest("base", dockerfile)); expect(devboxSourceDigest("base", withStep, 1)).toBe(devboxSourceDigest("base", dockerfile, 1)); + expect(devboxSourceDigest("base", `${dockerfile}\n# a comment changes no machine\n`)).toBe(devboxSourceDigest("base", dockerfile)); const bake = readScript("build-devbox-freestyle.ts"); const withCode = () => `${bake}\nconsole.log("one more step");\n`; - const withComment = () => `${bake}\n// a comment changes no machine\n/**\n * neither does a doc block\n */\n`; + // A `*`-prefixed code line (a continued multiplication, a generator + // method) is code, not a doc block: a change limited to it must move the + // digest, so no line heuristic drops it. + const withStar = () => bake.replace(/\nconst STEP_TIMEOUT_MS = 300_000;\n/, "\nconst STEP_TIMEOUT_MS = 300\n * 1_000;\n"); + const withStarChanged = () => bake.replace(/\nconst STEP_TIMEOUT_MS = 300_000;\n/, "\nconst STEP_TIMEOUT_MS = 300\n * 2_000;\n"); + expect(withStar()).not.toBe(bake); expect(devboxSourceDigest("base", dockerfile, 2, withCode)).not.toBe(devboxSourceDigest("base", dockerfile, 2, () => bake)); - expect(devboxSourceDigest("base", dockerfile, 2, withComment)).toBe(devboxSourceDigest("base", dockerfile, 2, () => bake)); + expect(devboxSourceDigest("base", dockerfile, 2, withStarChanged)).not.toBe(devboxSourceDigest("base", dockerfile, 2, withStar)); + // A comment edit to the bake script moves it too (stated trade-off: no lexer). + expect(devboxSourceDigest("base", dockerfile, 2, () => `${bake}\n// one more comment\n`)).not.toBe(devboxSourceDigest("base", dockerfile, 2, () => bake)); expect(devboxSourceDigest("base", dockerfile, 1, withCode)).toBe(devboxSourceDigest("base", dockerfile, 1, () => bake)); expect(() => devboxSourceDigest("base", dockerfile, 3)).toThrow(/unknown devbox source schema/); - // The normalizers themselves: comment and blank lines gone, code kept verbatim (trailing space trimmed). - expect(normalizedDockerfileInstructions("# c\n\nFROM ubuntu:24.04 \nRUN echo hi # keep\n # indented comment\n")).toBe("FROM ubuntu:24.04\nRUN echo hi # keep"); - expect(normalizedBakeScript("// c\n/**\n * doc\n */\nconst a = 1; // trailing stays\n\n const url = \"https://x\";\n")).toBe("const a = 1; // trailing stays\n const url = \"https://x\";"); + // The normalizers themselves: Dockerfile comments gone by the grammar + // (a parser directive before the first instruction is kept, a `#` line + // inside a continued RUN is a comment), bake-script lines kept verbatim + // but for blank lines and trailing whitespace. + expect(normalizedDockerfileInstructions("# syntax=docker/dockerfile:1\n# c\n\nFROM ubuntu:24.04 \nRUN apt-get install \\\n # inside a continuation\n cowsay\nRUN echo hi # keep\n# escape=`\n")).toBe("# syntax=docker/dockerfile:1\nFROM ubuntu:24.04\nRUN apt-get install \\\n cowsay\nRUN echo hi # keep"); + expect(normalizedBakeScript("// c\n/**\n * doc\n */\nconst a = 1 \n\n * 2;\n")).toBe("// c\n/**\n * doc\n */\nconst a = 1\n * 2;"); // Both bake entry points record the digest for the layers they baked. expect(readScript("build-devbox-freestyle.ts")).toContain('bakeMetadata(preflight, fileURLToPath(import.meta.url), withDesktop ? "desktop" : "base")'); expect(readScript("promote-devbox-image.ts")).toContain("devboxSourceDriftProblems({ ...next, images: added })"); diff --git a/web/tests/vm-image-manifest.test.ts b/web/tests/vm-image-manifest.test.ts index 57352f728df..c6616d5f90e 100644 --- a/web/tests/vm-image-manifest.test.ts +++ b/web/tests/vm-image-manifest.test.ts @@ -4,6 +4,9 @@ import { DEVBOX_SOURCE_SCHEMA, appendImageManifestEntries, bakeMetadata, + bakeScriptPath, + sha256File, + upgradeDevboxSourceRecords, devboxImageEpoch, devboxImageLadderProblems, devboxSourceDigest, @@ -250,6 +253,53 @@ describe("promoteImageManifestEntry", () => { }); }); +describe("upgradeDevboxSourceRecords (promote --upgrade-source-schema)", () => { + const dockerfile = readDevboxDockerfile(); + const epoch = devboxImageEpoch(dockerfile); + const bakeScriptSha = sha256File(bakeScriptPath); + const recorded = (layers: "desktop" | "base", overrides: Partial = {}): DevboxManifestEntry => + passedEntry({ + version: `freestyle-${layers}-v1`, + imageId: `sh-${layers}-v1`, + kind: layers, + defaultForKind: true, + epoch, + builderScriptVersion: bakeScriptSha, + devboxSource: { layers, digest: devboxSourceDigest(layers, dockerfile, 1) }, + ...overrides, + }); + const manifestOf = (...images: DevboxManifestEntry[]): DevboxImageManifest => ({ schemaVersion: 1, images }); + + test("moves a schema-1 default up only when its digest and builderScriptVersion prove the checkout", () => { + const result = upgradeDevboxSourceRecords(manifestOf(recorded("desktop"), recorded("base"))); + expect(result.upgraded).toEqual(["freestyle-desktop-v1", "freestyle-base-v1"]); + expect(result.skipped).toEqual([]); + for (const entry of result.manifest.images) { + expect(entry.devboxSource).toEqual({ layers: entry.kind, digest: devboxSourceDigest(entry.kind!, dockerfile, DEVBOX_SOURCE_SCHEMA), schema: DEVBOX_SOURCE_SCHEMA }); + } + expect(devboxSourceDriftProblems(result.manifest)).toEqual([]); + // Idempotent: nothing left to upgrade. + expect(upgradeDevboxSourceRecords(result.manifest).upgraded).toEqual([]); + }); + + test("leaves an entry alone when its provenance is not proven, and never touches non-defaults", () => { + const stale = recorded("base", { version: "stale", builderScriptVersion: "0".repeat(64) }); + const drifted = recorded("base", { version: "drifted", devboxSource: { layers: "base", digest: "1".repeat(64) } }); + const demoted = recorded("base", { version: "demoted", defaultForKind: false }); + const legacy = passedEntry({ version: "legacy", kind: "base", defaultForKind: true, epoch }); + const result = upgradeDevboxSourceRecords(manifestOf(stale, drifted, demoted, legacy)); + expect(result.upgraded).toEqual([]); + expect(result.skipped.map((row) => [row.version, row.reason])).toEqual([ + ["stale", "builderScriptVersion does not match this checkout's bake script"], + ["drifted", "schema 1 digest does not match this checkout"], + ]); + expect(result.manifest.images.every((e) => (e.devboxSource?.schema ?? 1) === 1)).toBe(true); + // A different bake script than the one the entry recorded is not proven either. + const other = upgradeDevboxSourceRecords(manifestOf(recorded("base")), { bakeScript: () => "export {};\n" }); + expect(other.upgraded).toEqual([]); + }); +}); + describe("appendImageManifestEntries (promote --replay)", () => { // Two promotions in flight append to the same manifest. Whichever merges // second replays the rows its promotion appended (the --out summary's From e395e02aaefc2de8ad3732ecce64e5035ab156e7 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 9 Sep 2026 21:39:21 -0700 Subject: [PATCH 06/18] cloud: the source-schema upgrade proves the Dockerfile at the entry's bake commit, never synthesizes it Review finding (CodeRabbit on #12250, third pass): a schema-1 record proves the verbatim files, pins and epoch, and builderScriptVersion proves the bake script, but nothing in it proves the Dockerfile's instructions, which schema 2 adds. upgradeDevboxSourceRecords now reads the Dockerfile at the entry's repoCommit from git and requires its normalized instructions to equal this checkout's; a commit or file that is not available, or an instruction change since the bake, keeps the entry at schema 1 and asks for a rebake. Regression tests: a Dockerfile-only instruction change, an unavailable commit, a comment-only difference (same recipe). The 12 agents0909 defaults already recorded at schema 2 (c690d875b0) hold under this rule: the Dockerfile at d3b2da01be is byte-identical to the checkout's, and re-running the strict upgrade on the pre-upgrade manifest yields the same digests. Co-Authored-By: Claude Fable 5.1 --- web/scripts/devbox-image-common.ts | 44 +++++++++++++++++++----- web/services/vms/images/devbox/README.md | 9 +++-- web/tests/vm-image-manifest.test.ts | 34 +++++++++++++++--- 3 files changed, 70 insertions(+), 17 deletions(-) diff --git a/web/scripts/devbox-image-common.ts b/web/scripts/devbox-image-common.ts index 3d68280c6a4..6dc2e53a650 100644 --- a/web/scripts/devbox-image-common.ts +++ b/web/scripts/devbox-image-common.ts @@ -1076,25 +1076,42 @@ export function devboxImageLadderProblems( } +/** The devbox Dockerfile as committed at `commit`, or null when the commit or the file is not available here. */ +export function devboxDockerfileAtCommit(commit: string): string | null { + try { + return execSync(`git show ${commit}:web/services/vms/images/devbox/Dockerfile`, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + } catch { + return null; + } +} + /** * Moves default entries recorded at an older source schema to the current - * one without a rebake, only where the provenance is already proven by what - * the entry recorded: its digest at its own schema must equal this checkout's - * (the verbatim files, pins and epoch are the bake's), and its - * `builderScriptVersion` must equal this checkout's bake script (the one - * input the newer schema adds that the older one did not cover; the - * Dockerfile instructions are covered by the same ARG-derived pins plus the - * fact that the Freestyle replay reads nothing else from it). Anything else - * is left alone and reported: a rebake is the only other way up. Pure. + * one without a rebake, only where every input the newer schema adds is + * proven from what the entry recorded, never synthesized from the checkout: + * its digest at its own schema must equal this checkout's (the verbatim + * files, pins and epoch are the bake's); its `builderScriptVersion` must + * equal this checkout's bake script; and the Dockerfile's instructions at + * its `repoCommit` (read from git; a commit or file that is not available + * here is not proof) must equal this checkout's. Anything else is left alone + * and reported: a rebake is the only other way up. Pure but for the git read, + * which `dockerfileAt` replaces in tests. */ export function upgradeDevboxSourceRecords( manifest: DevboxImageManifest, - options: { provider?: DevboxProvider; dockerfile?: string; bakeScript?: () => string } = {}, + options: { + provider?: DevboxProvider; + dockerfile?: string; + bakeScript?: () => string; + dockerfileAt?: (commit: string) => string | null; + } = {}, ): { manifest: DevboxImageManifest; upgraded: string[]; skipped: Array<{ version: string; reason: string }> } { const provider = options.provider ?? "freestyle"; const dockerfile = options.dockerfile ?? readDevboxDockerfile(); const bakeScript = options.bakeScript ?? (() => readFileSync(bakeScriptPath, "utf8")); + const dockerfileAt = options.dockerfileAt ?? devboxDockerfileAtCommit; const bakeScriptSha256 = createHash("sha256").update(bakeScript()).digest("hex"); + const instructions = normalizedDockerfileInstructions(dockerfile); const upgraded: string[] = []; const skipped: Array<{ version: string; reason: string }> = []; const images = manifest.images.map((entry) => { @@ -1114,6 +1131,15 @@ export function upgradeDevboxSourceRecords( skipped.push({ version: entry.version, reason: "builderScriptVersion does not match this checkout's bake script" }); return entry; } + const bakedDockerfile = entry.repoCommit ? dockerfileAt(entry.repoCommit) : null; + if (bakedDockerfile === null) { + skipped.push({ version: entry.version, reason: `Dockerfile at repoCommit ${entry.repoCommit ?? "(none)"} is not available here; rebake to record schema ${DEVBOX_SOURCE_SCHEMA}` }); + return entry; + } + if (normalizedDockerfileInstructions(bakedDockerfile) !== instructions) { + skipped.push({ version: entry.version, reason: `Dockerfile instructions changed since repoCommit ${entry.repoCommit}; rebake to record schema ${DEVBOX_SOURCE_SCHEMA}` }); + return entry; + } upgraded.push(entry.version); return { ...entry, diff --git a/web/services/vms/images/devbox/README.md b/web/services/vms/images/devbox/README.md index f713fadaa99..68dc46649cc 100644 --- a/web/services/vms/images/devbox/README.md +++ b/web/services/vms/images/devbox/README.md @@ -67,9 +67,12 @@ writes): checkout's sources. An entry is checked with the schema it was recorded with (absent: 1), so a formula change never forces a rebake; new bakes record `DEVBOX_SOURCE_SCHEMA`, and `bun run devbox:promote -- freestyle - --upgrade-source-schema` moves older defaults up without a bake where - their recorded digest and `builderScriptVersion` prove the checkout is - what they were baked from (anything else is kept and reported). + --upgrade-source-schema` moves older defaults up without a bake only where + every input the newer schema adds is proven from what the entry recorded: + its digest at its own schema, its `builderScriptVersion` against this + checkout's bake script, and the Dockerfile's instructions at its + `repoCommit` (read from git) against this checkout's. Anything else is + kept and reported; a rebake is the only other way up. Rollback is therefore a revert of the promotion commit as a whole, never the manifest flags alone. diff --git a/web/tests/vm-image-manifest.test.ts b/web/tests/vm-image-manifest.test.ts index c6616d5f90e..53af3f7b8de 100644 --- a/web/tests/vm-image-manifest.test.ts +++ b/web/tests/vm-image-manifest.test.ts @@ -264,14 +264,17 @@ describe("upgradeDevboxSourceRecords (promote --upgrade-source-schema)", () => { kind: layers, defaultForKind: true, epoch, + repoCommit: "bakecommit", builderScriptVersion: bakeScriptSha, devboxSource: { layers, digest: devboxSourceDigest(layers, dockerfile, 1) }, ...overrides, }); const manifestOf = (...images: DevboxManifestEntry[]): DevboxImageManifest => ({ schemaVersion: 1, images }); + // The Dockerfile as committed at the entry's repoCommit, in tests a stand-in for `git show`. + const sameDockerfile = (commit: string) => (commit === "bakecommit" ? dockerfile : null); - test("moves a schema-1 default up only when its digest and builderScriptVersion prove the checkout", () => { - const result = upgradeDevboxSourceRecords(manifestOf(recorded("desktop"), recorded("base"))); + test("moves a schema-1 default up only when its digest, builderScriptVersion and baked Dockerfile prove the checkout", () => { + const result = upgradeDevboxSourceRecords(manifestOf(recorded("desktop"), recorded("base")), { dockerfileAt: sameDockerfile }); expect(result.upgraded).toEqual(["freestyle-desktop-v1", "freestyle-base-v1"]); expect(result.skipped).toEqual([]); for (const entry of result.manifest.images) { @@ -279,7 +282,10 @@ describe("upgradeDevboxSourceRecords (promote --upgrade-source-schema)", () => { } expect(devboxSourceDriftProblems(result.manifest)).toEqual([]); // Idempotent: nothing left to upgrade. - expect(upgradeDevboxSourceRecords(result.manifest).upgraded).toEqual([]); + expect(upgradeDevboxSourceRecords(result.manifest, { dockerfileAt: sameDockerfile }).upgraded).toEqual([]); + // A Dockerfile that differs only in comments at the bake commit is the same recipe. + const commented = upgradeDevboxSourceRecords(manifestOf(recorded("base")), { dockerfileAt: () => `# a comment\n${dockerfile}\n# another\n` }); + expect(commented.upgraded).toEqual(["freestyle-base-v1"]); }); test("leaves an entry alone when its provenance is not proven, and never touches non-defaults", () => { @@ -287,17 +293,35 @@ describe("upgradeDevboxSourceRecords (promote --upgrade-source-schema)", () => { const drifted = recorded("base", { version: "drifted", devboxSource: { layers: "base", digest: "1".repeat(64) } }); const demoted = recorded("base", { version: "demoted", defaultForKind: false }); const legacy = passedEntry({ version: "legacy", kind: "base", defaultForKind: true, epoch }); - const result = upgradeDevboxSourceRecords(manifestOf(stale, drifted, demoted, legacy)); + const noCommit = recorded("base", { version: "nocommit", repoCommit: undefined }); + const result = upgradeDevboxSourceRecords(manifestOf(stale, drifted, demoted, legacy, noCommit), { dockerfileAt: sameDockerfile }); expect(result.upgraded).toEqual([]); expect(result.skipped.map((row) => [row.version, row.reason])).toEqual([ ["stale", "builderScriptVersion does not match this checkout's bake script"], ["drifted", "schema 1 digest does not match this checkout"], + ["nocommit", `Dockerfile at repoCommit (none) is not available here; rebake to record schema ${DEVBOX_SOURCE_SCHEMA}`], ]); expect(result.manifest.images.every((e) => (e.devboxSource?.schema ?? 1) === 1)).toBe(true); // A different bake script than the one the entry recorded is not proven either. - const other = upgradeDevboxSourceRecords(manifestOf(recorded("base")), { bakeScript: () => "export {};\n" }); + const other = upgradeDevboxSourceRecords(manifestOf(recorded("base")), { bakeScript: () => "export {};\n", dockerfileAt: sameDockerfile }); expect(other.upgraded).toEqual([]); }); + + test("a Dockerfile-only instruction change since the bake commit is not proven: no upgrade, rebake", () => { + // Regression: a schema-1 record carries no Dockerfile instruction hash, + // so the checkout's instructions must equal those at repoCommit before + // schema 2 may claim them; an unavailable commit is not proof either. + const bakedDockerfile = dockerfile.replace(/^ bubblewrap \\$/m, " bubblewrap \\\n cowsay \\"); + expect(bakedDockerfile).not.toBe(dockerfile); + const changed = upgradeDevboxSourceRecords(manifestOf(recorded("base")), { dockerfileAt: () => bakedDockerfile }); + expect(changed.upgraded).toEqual([]); + expect(changed.skipped).toEqual([{ version: "freestyle-base-v1", reason: `Dockerfile instructions changed since repoCommit bakecommit; rebake to record schema ${DEVBOX_SOURCE_SCHEMA}` }]); + const unavailable = upgradeDevboxSourceRecords(manifestOf(recorded("base")), { dockerfileAt: () => null }); + expect(unavailable.upgraded).toEqual([]); + expect(unavailable.skipped[0]?.reason).toContain("is not available here"); + // The real reader: the Dockerfile at this PR's bake commit is what the promoted defaults were checked against. + expect(upgradeDevboxSourceRecords(manifestOf(recorded("base", { repoCommit: "0000000000000000000000000000000000000000" }))).skipped[0]?.reason).toContain("is not available here"); + }); }); describe("appendImageManifestEntries (promote --replay)", () => { From 496ea222aa406f3912dbb708c0eb5be44103fa36 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 9 Sep 2026 21:40:39 -0700 Subject: [PATCH 07/18] cloud: devbox README states that bake-script comments are part of the schema-2 digest Review follow-up (CodeRabbit on #12250): the README still said the bake script's code was hashed with comments dropped, while schema 2 hashes every non-blank line of build-devbox-freestyle.ts on purpose (no TypeScript lexer; a line heuristic can hide a code change). The README now says so and why. Co-Authored-By: Claude Fable 5.1 --- web/services/vms/images/devbox/README.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/web/services/vms/images/devbox/README.md b/web/services/vms/images/devbox/README.md index 68dc46649cc..d855592ae26 100644 --- a/web/services/vms/images/devbox/README.md +++ b/web/services/vms/images/devbox/README.md @@ -61,10 +61,14 @@ writes): - an entry that recorded `devboxSource` (`{ layers, digest, schema }`, `devboxSourceDigest()`: sha256 over the files the bake ships verbatim, the agent, cua-driver and Ghostty pins, the desktop apt list, the epoch and, - from schema 2, the Dockerfile's instructions and the bake script's code - with comment and blank lines dropped, per layer set; prose is excluded - because a comment cannot change a machine) was baked from exactly this - checkout's sources. An entry is checked with the schema it was recorded + from schema 2, the Dockerfile's instructions (its comments dropped by the + Dockerfile grammar, parser directives kept) and every non-blank line of + `build-devbox-freestyle.ts`, comments included, per layer set. Dockerfile + prose is excluded because a comment cannot change a machine; bake-script + comments are intentionally part of the digest, because telling a comment + from code in TypeScript needs a full lexer and any line heuristic can hide + a code change, so a comment-only edit to the bake script also asks for a + re-promotion) was baked from exactly this checkout's sources. An entry is checked with the schema it was recorded with (absent: 1), so a formula change never forces a rebake; new bakes record `DEVBOX_SOURCE_SCHEMA`, and `bun run devbox:promote -- freestyle --upgrade-source-schema` moves older defaults up without a bake only where From 6d735f0e49f47c36c33dd600bb8c335d059b5152 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 9 Sep 2026 21:51:06 -0700 Subject: [PATCH 08/18] cloud: pass a manifest entry's repoCommit to git as an argument, never shell text Review finding (CodeRabbit on #12250, CWE-78): devboxDockerfileAtCommit interpolated the entry's repoCommit into an execSync shell string, and a manifest processed by --upgrade-source-schema may come from a branch this checkout did not author. The commit is now accepted only as a full 40-hex object id and passed to execFileSync("git", ["show", ...]). Tests: refs, short ids, shell metacharacters and path tricks are refused before git runs; a real object id resolves. Co-Authored-By: Claude Fable 5.1 --- web/scripts/devbox-image-common.ts | 17 ++++++++++++++--- web/tests/vm-image-manifest.test.ts | 16 +++++++++++++++- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/web/scripts/devbox-image-common.ts b/web/scripts/devbox-image-common.ts index 6dc2e53a650..dc7c9131e51 100644 --- a/web/scripts/devbox-image-common.ts +++ b/web/scripts/devbox-image-common.ts @@ -13,7 +13,7 @@ * (web/services/vms/drivers/cmuxTuiDaemon.ts); the image only ships the * cmux-devbox-boot supervisor. */ -import { execSync } from "node:child_process"; +import { execFileSync, execSync } from "node:child_process"; import { createHash } from "node:crypto"; import { Buffer } from "node:buffer"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; @@ -1076,10 +1076,21 @@ export function devboxImageLadderProblems( } -/** The devbox Dockerfile as committed at `commit`, or null when the commit or the file is not available here. */ +/** + * The devbox Dockerfile as committed at `commit`, or null when the commit or + * the file is not available here. `commit` comes from a manifest entry, which + * may have been written on a branch this checkout did not author, so it is + * accepted only as a full 40-hex object id and passed to git as an argument, + * never through a shell. + */ export function devboxDockerfileAtCommit(commit: string): string | null { + if (!/^[0-9a-f]{40}$/i.test(commit)) return null; try { - return execSync(`git show ${commit}:web/services/vms/images/devbox/Dockerfile`, { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }); + return execFileSync("git", ["show", `${commit}:web/services/vms/images/devbox/Dockerfile`], { + cwd: repoRoot, + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); } catch { return null; } diff --git a/web/tests/vm-image-manifest.test.ts b/web/tests/vm-image-manifest.test.ts index 53af3f7b8de..369f44c1e60 100644 --- a/web/tests/vm-image-manifest.test.ts +++ b/web/tests/vm-image-manifest.test.ts @@ -1,10 +1,12 @@ import { describe, expect, test } from "bun:test"; import path from "node:path"; +import { spawnSync } from "node:child_process"; import { DEVBOX_SOURCE_SCHEMA, appendImageManifestEntries, bakeMetadata, bakeScriptPath, + devboxDockerfileAtCommit, sha256File, upgradeDevboxSourceRecords, devboxImageEpoch, @@ -319,8 +321,20 @@ describe("upgradeDevboxSourceRecords (promote --upgrade-source-schema)", () => { const unavailable = upgradeDevboxSourceRecords(manifestOf(recorded("base")), { dockerfileAt: () => null }); expect(unavailable.upgraded).toEqual([]); expect(unavailable.skipped[0]?.reason).toContain("is not available here"); - // The real reader: the Dockerfile at this PR's bake commit is what the promoted defaults were checked against. + // The real reader: an unknown but well-formed object id is not available; + // anything that is not a full 40-hex object id is refused before git runs + // (a manifest entry may come from a branch this checkout did not author, + // so `repoCommit` is an argument to git, never shell text). expect(upgradeDevboxSourceRecords(manifestOf(recorded("base", { repoCommit: "0000000000000000000000000000000000000000" }))).skipped[0]?.reason).toContain("is not available here"); + for (const hostile of ["HEAD", "main", "d3b2da01be", "$(touch /tmp/pwned)", "x; echo pwned", "0".repeat(39) + ":../../etc/passwd"]) { + expect(devboxDockerfileAtCommit(hostile)).toBeNull(); + expect(upgradeDevboxSourceRecords(manifestOf(recorded("base", { repoCommit: hostile }))).upgraded).toEqual([]); + } + // A real full object id that carries the file resolves through git. + const bakeCommit = spawnSync("git", ["rev-parse", "d3b2da01be"], { cwd: path.join(import.meta.dirname, "../.."), encoding: "utf8" }).stdout.trim(); + if (/^[0-9a-f]{40}$/.test(bakeCommit)) { + expect(devboxDockerfileAtCommit(bakeCommit)).toContain("CMUX_IMAGE_EPOCH="); + } }); }); From 4a02de458266f5fefed5b22fd2b05ed14a541675 Mon Sep 17 00:00:00 2001 From: Austin Wang Date: Wed, 9 Sep 2026 21:59:16 -0700 Subject: [PATCH 09/18] cloud: the git-backed Dockerfile lookup test asserts on HEAD unconditionally Review follow-up (CodeRabbit on #12250): the positive case skipped itself when the historical bake commit was not in the checkout. It now resolves HEAD, which every checkout has, requires a full object id, and asserts the lookup returns the Dockerfile. Co-Authored-By: Claude Fable 5.1 --- web/tests/vm-image-manifest.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/tests/vm-image-manifest.test.ts b/web/tests/vm-image-manifest.test.ts index 369f44c1e60..45502286756 100644 --- a/web/tests/vm-image-manifest.test.ts +++ b/web/tests/vm-image-manifest.test.ts @@ -330,11 +330,11 @@ describe("upgradeDevboxSourceRecords (promote --upgrade-source-schema)", () => { expect(devboxDockerfileAtCommit(hostile)).toBeNull(); expect(upgradeDevboxSourceRecords(manifestOf(recorded("base", { repoCommit: hostile }))).upgraded).toEqual([]); } - // A real full object id that carries the file resolves through git. - const bakeCommit = spawnSync("git", ["rev-parse", "d3b2da01be"], { cwd: path.join(import.meta.dirname, "../.."), encoding: "utf8" }).stdout.trim(); - if (/^[0-9a-f]{40}$/.test(bakeCommit)) { - expect(devboxDockerfileAtCommit(bakeCommit)).toContain("CMUX_IMAGE_EPOCH="); - } + // A real full object id that carries the file resolves through git: HEAD + // exists in every checkout, shallow ones included. + const head = spawnSync("git", ["rev-parse", "HEAD"], { cwd: path.join(import.meta.dirname, "../.."), encoding: "utf8" }).stdout.trim(); + expect(head).toMatch(/^[0-9a-f]{40}$/); + expect(devboxDockerfileAtCommit(head)).toContain("CMUX_IMAGE_EPOCH="); }); }); From b976585870d72851df360713c901203bed37cb7b Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 9 Sep 2026 23:01:28 -0700 Subject: [PATCH 10/18] test: require one devbox snapshot ladder and no machine kind switcher --- cmuxUITests/NewMachineSheetKindUITests.swift | 71 +++----------------- web/tests/vm-image-manifest.test.ts | 27 ++++++++ web/tests/vm-image-resolver.test.ts | 11 +++ 3 files changed, 49 insertions(+), 60 deletions(-) diff --git a/cmuxUITests/NewMachineSheetKindUITests.swift b/cmuxUITests/NewMachineSheetKindUITests.swift index 6bf07a19fef..76d7f5339bb 100644 --- a/cmuxUITests/NewMachineSheetKindUITests.swift +++ b/cmuxUITests/NewMachineSheetKindUITests.swift @@ -1,15 +1,13 @@ import XCTest -/// #12239: the New Machine sheet opens on Desktop (a machine with a VNC -/// screen) and offers Base as an explicit choice; the summary under the -/// picker describes whichever kind is picked. +/// New Machine has one creation flow with no Desktop/Base switcher. final class NewMachineSheetKindUITests: XCTestCase { override func setUp() { super.setUp() continueAfterFailure = false } - func testNewMachineSheetPreselectsDesktopAndOffersBase() throws { + func testNewMachineSheetHasOneFlowWithoutAKindSwitcher() throws { let app = XCUIApplication.cmuxTestApplication() app.launchArguments += [ "-AppleLanguages", "(en)", "-AppleLocale", "en_US", "-menuBarOnly", "false", @@ -27,7 +25,7 @@ final class NewMachineSheetKindUITests: XCTestCase { // The palette's New Cloud Machine… runs the same presenter path the // Machines panel + uses. Signed out, the sheet still opens (the plan - // meter is simply absent) with every kind on offer. + // meter is simply absent) without a kind switcher. let searchField = app.textFields["CommandPaletteSearchField"] app.typeKey("p", modifierFlags: [.command, .shift]) XCTAssertTrue(searchField.waitForExistence(timeout: 5.0), "Expected command palette search field") @@ -43,67 +41,20 @@ final class NewMachineSheetKindUITests: XCTestCase { XCTAssertTrue(row.waitForExistence(timeout: 5.0), "Expected the New Cloud Machine… palette row") row.click() - // SwiftUI's segmented Picker exposes its segments as radio buttons and - // drops the picker's own identifier, so the segments are the handle. - // A segment's selection shows as its accessibility value (1 = on); - // the summary under the picker is the user-visible witness of the - // selection, so it is what the assertions rest on. - let desktop = app.radioButtons["Desktop"] - let base = app.radioButtons["Base"] - if !desktop.waitForExistence(timeout: 8.0) { - print("NewMachineSheetKindUITests hierarchy:\n\(app.debugDescription.prefix(6000))") - } - XCTAssertTrue(desktop.exists, "Expected the Desktop segment of the Kind picker in the New Machine sheet") - XCTAssertTrue(base.exists, "Expected the Base segment of the Kind picker") - attachScreenshot(of: app, named: "new-machine-sheet-opened") - print("NewMachineSheetKindUITests segments: desktop=\(String(describing: desktop.value)) selected=\(desktop.isSelected) base=\(String(describing: base.value)) selected=\(base.isSelected)") - let desktopSummary = app.staticTexts.matching( - NSPredicate(format: "label CONTAINS[c] %@", "screen you can watch") - ).firstMatch - let baseSummary = app.staticTexts.matching( - NSPredicate(format: "label CONTAINS[c] %@", "terminal only") - ).firstMatch - XCTAssertTrue( - desktopSummary.waitForExistence(timeout: 3.0), - "A plain Create must make a machine with a screen: the sheet opens on Desktop" - ) - XCTAssertFalse(baseSummary.exists, "Base must not be the preselected kind") - if let desktopOn = Self.segmentIsOn(desktop), let baseOn = Self.segmentIsOn(base) { - XCTAssertTrue(desktopOn && !baseOn, "Desktop segment should be the selected one") - } - attachScreenshot(of: app, named: "new-machine-sheet-desktop-preselected") - - // Base is one click away, never the default. - base.click() - XCTAssertTrue( - pollUntil(timeout: 4.0) { baseSummary.exists && !desktopSummary.exists }, - "Expected the picker to select Base and the summary to say terminal only" - ) - if let desktopOn = Self.segmentIsOn(desktop), let baseOn = Self.segmentIsOn(base) { - XCTAssertTrue(baseOn && !desktopOn, "Base segment should be the selected one after the click") - } - attachScreenshot(of: app, named: "new-machine-sheet-base-explicit") + let create = app.buttons["NewMachineSheet.create"] + XCTAssertTrue(create.waitForExistence(timeout: 8.0), "Expected New Machine to open") + XCTAssertEqual(app.buttons.matching(identifier: "NewMachineSheet.create").count, 1) + XCTAssertFalse(app.radioButtons["Desktop"].exists) + XCTAssertFalse(app.radioButtons["Base"].exists) + XCTAssertFalse(app.descendants(matching: .any)["NewMachineSheet.kindSection"].exists) + attachScreenshot(of: app, named: "new-machine-single-flow") let cancel = app.buttons["NewMachineSheet.cancel"].exists ? app.buttons["NewMachineSheet.cancel"] : app.buttons["Cancel"] XCTAssertTrue(cancel.waitForExistence(timeout: 3.0), "Expected the sheet's Cancel button") cancel.click() - XCTAssertTrue(pollUntil(timeout: 5.0) { !desktop.exists }, "Cancel should close the sheet") - } - - /// A segmented control's segment reports its selection as an accessibility - /// value (1 / 0, sometimes a string); nil when the value is not readable. - private static func segmentIsOn(_ segment: XCUIElement) -> Bool? { - if let number = segment.value as? NSNumber { return number.intValue != 0 } - if let text = segment.value as? String { - switch text.lowercased() { - case "1", "on", "true", "selected": return true - case "0", "off", "false": return false - default: return nil - } - } - return nil + XCTAssertTrue(pollUntil(timeout: 5.0) { !create.exists }, "Cancel should close the sheet") } private func attachScreenshot(of app: XCUIApplication, named name: String) { diff --git a/web/tests/vm-image-manifest.test.ts b/web/tests/vm-image-manifest.test.ts index 45502286756..ec35cab2649 100644 --- a/web/tests/vm-image-manifest.test.ts +++ b/web/tests/vm-image-manifest.test.ts @@ -253,6 +253,33 @@ describe("promoteImageManifestEntry", () => { ).toThrow(/already listed as freestyle-old-desktop \(desktop\)/); expect(() => promoteImageManifestEntry(base, passedEntry(), { kinds: [] })).toThrow(/no kinds/); }); + + test("adds a kind to an image promoted earlier without re-listing the rows it already has", () => { + // One snapshot serving both kinds, promoted in two steps: the desktop + // rows first, then `--kinds desktop,base` from the same bake. The desktop + // rows are left as they are, the base rows are appended with the `-base` + // suffix, and the provider's previous base defaults are demoted. + const sizes = [ + { imageId: "sh-x-sm", size: { name: "sm" as const, cpu: 2, memoryMb: 4096, storageMb: 16384 } }, + { imageId: "sh-x-md", size: { name: "md" as const, cpu: 4, memoryMb: 8192, storageMb: 32768 } }, + ]; + const withDesktop = promoteImageManifestEntry(base, passedEntry({ version: "freestyle-x" }), { kinds: ["desktop"], sizes }); + const both = promoteImageManifestEntry(withDesktop, passedEntry({ version: "freestyle-x" }), { kinds: ["desktop", "base"], sizes }); + expect(imageManifestProblems(both)).toEqual([]); + expect(both.images.slice(withDesktop.images.length).map((e) => [e.version, e.kind, e.imageId, e.defaultForLocalDev ?? false])).toEqual([ + ["freestyle-x-sm-base", "base", "sh-x-sm", true], + ["freestyle-x-md-base", "base", "sh-x-md", false], + ]); + expect(both.images.filter((e) => e.provider === "freestyle" && e.defaultForKind).map((e) => [e.version, e.kind])).toEqual([ + ["freestyle-x-sm", "desktop"], + ["freestyle-x-md", "desktop"], + ["freestyle-x-sm-base", "base"], + ["freestyle-x-md-base", "base"], + ]); + expect(both.images.find((e) => e.version === "freestyle-old-base")?.defaultForKind).toBe(false); + // Nothing left to add: refused, not silently a no-op. + expect(() => promoteImageManifestEntry(both, passedEntry({ version: "freestyle-x" }), { kinds: ["desktop", "base"], sizes })).toThrow(/already listed as freestyle-x-sm \(desktop, sm\)/); + }); }); describe("upgradeDevboxSourceRecords (promote --upgrade-source-schema)", () => { diff --git a/web/tests/vm-image-resolver.test.ts b/web/tests/vm-image-resolver.test.ts index c21c48684e2..a0338296e62 100644 --- a/web/tests/vm-image-resolver.test.ts +++ b/web/tests/vm-image-resolver.test.ts @@ -70,6 +70,17 @@ const legacyDesktopVersion = "freestyle-cmux-devbox-20260902h"; const retiredBetaSnapshot = "sh-fb3dcf7b47894114889b10186626af5b"; describe("VM image resolver: request by kind", () => { + test("every size resolves legacy Base and Desktop requests to one snapshot with displays", () => { + for (const memoryMb of [4096, 8192, 16384, 24576, 32768, 65536]) { + const desktop = resolveVmImage("freestyle", undefined, {}, { kind: "desktop", memoryMb }); + const base = resolveVmImage("freestyle", undefined, {}, { kind: "base", memoryMb }); + const implicit = resolveVmImage("freestyle", undefined, {}, { memoryMb }); + expect(base.image).toBe(desktop.image); + expect(implicit.image).toBe(desktop.image); + expect(vmImageKindFor("freestyle", base.image)).toBe("desktop"); + } + }); + const deployed = { VERCEL: "1", VERCEL_ENV: "production" }; test("FREESTYLE_SANDBOX_SNAPSHOT is ignored: only the manifest decides", () => { From 297c4e92322f361ece373df53f0ab4f6eafa1969 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 9 Sep 2026 23:03:24 -0700 Subject: [PATCH 11/18] test: report display capability for legacy machine create requests --- web/tests/vm-route-auth.test.ts | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index 21baa840e6b..2b562df9c2c 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -534,7 +534,7 @@ describe("VM REST auth", () => { expect(runVmWorkflow).not.toHaveBeenCalled(); }); - test("creates by kind without an image and echoes the resolved kind", async () => { + test("legacy Base creates report the actual desktop capability", async () => { // The deployed shape: the manifest's defaultForKind entry names the image, // and the client only asks for a kind. getUser.mockResolvedValue(authedStackUser()); @@ -555,7 +555,7 @@ describe("VM REST auth", () => { ); expect(response.status).toBe(200); - expect(await response.json()).toMatchObject({ id: "provider-vm-kind", kind: "base" }); + expect(await response.json()).toMatchObject({ id: "provider-vm-kind", kind: "desktop" }); expect(createVm).toHaveBeenCalledWith(expect.objectContaining({ provider: "freestyle", image: MANIFEST_BASE_DEFAULT.imageId, @@ -563,6 +563,24 @@ describe("VM REST auth", () => { })); }); + test.each(["open", "reset"])("Base %s reports the returned machine capability", async (operation) => { + getUser.mockResolvedValue(authedStackUser()); + const route = operation === "open" ? baseOpenRoute : baseResetRoute; + for (const [image, kind] of [[MANIFEST_DESKTOP_DEFAULT.imageId, "desktop"], ["sh-never-listed", "base"]]) { + runVmWorkflow.mockResolvedValue({ + providerVmId: "provider-vm-base", provider: "freestyle", image, + imageVersion: null, status: "running", createdAt: 1_777_000_000_000, + baseId: "base-test", baseName: "Base", generation: 1, + }); + const response = await route.POST(new Request(`https://cmux.test/api/vm/base/${operation}`, { + method: "POST", headers: { origin: "https://cmux.test" }, + body: JSON.stringify({ kind: "base" }), + })); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ image, kind }); + } + }); + test("a plan size the manifest ladder cannot serve fails with an actionable image config error", async () => { // Both kinds have a manifest ladder, so the only way nothing resolves is a // plan machine above the ladder's largest snapshot (2xl, 64 GiB). The From 1b2ef5f6929367009ebb355253ab1ddf9d9b0724 Mon Sep 17 00:00:00 2001 From: austinpower1258 Date: Wed, 9 Sep 2026 23:04:50 -0700 Subject: [PATCH 12/18] fix: restore one new-machine flow and unify the devbox snapshot ladder --- CLI/cmux.swift | 47 ++-- Resources/Localizable.xcstrings | 187 +++++++++------ Sources/AppDelegate.swift | 1 - Sources/Cloud/MachinesPanelView.swift | 7 +- Sources/Cloud/MachinesPanelViewModel.swift | 2 - Sources/Cloud/NewMachineModel.swift | 65 ++---- Sources/Cloud/NewMachineSheet.swift | 35 +-- Sources/Cloud/NewMachineSheetPresenter.swift | 10 +- Sources/Cloud/VMClient.swift | 2 +- Sources/Cloud/VMMachineKind.swift | 24 +- cmuxTests/NewMachineModelTests.swift | 54 ++--- docs/cli-contract.md | 4 +- web/app/api/vm/base/routeShared.ts | 3 +- web/app/api/vm/route.ts | 2 +- web/scripts/devbox-image-common.ts | 46 +++- web/scripts/promote-devbox-image.ts | 9 +- web/services/vms/README.md | 18 +- web/services/vms/images/devbox/README.md | 39 ++-- web/services/vms/images/manifest.json | 230 ++++++++++++++++++- web/tests/vm-image-manifest.test.ts | 15 ++ web/tests/vm-image-resolver.test.ts | 16 +- web/tests/vm-route-auth.test.ts | 2 +- 22 files changed, 511 insertions(+), 307 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 4629de39c73..c492f9cf22b 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -4364,17 +4364,15 @@ struct CMUXCLI { guard let gib = Int(number), (4...256).contains(gib), gib % 4 == 0 else { return nil } return gib * 1024 } - /// The kind the caller explicitly requested, or nil when no kind flag was - /// given (the create verbs then send `VMMachineKind.defaultKind`, a desktop). - static func parseExplicitCloudVMKindFlag(_ args: [String], command: String) throws -> VMMachineKind? { + /// All create verbs provision the same devbox. Legacy kind flags remain + /// accepted; contradictory flags still report the caller's mistake. + static func cloudVMCreateKind(_ args: [String], command: String) throws -> VMMachineKind { let requestsBase = args.contains("--base") || args.contains("--no-desktop") let requestsDesktop = args.contains("--desktop") if requestsBase && requestsDesktop { throw CLIError(message: "\(command): choose one of --base or --desktop") } - if requestsBase { return .base } - if requestsDesktop { return .desktop } - return nil + return VMMachineKind.defaultKind } private static let cloudVMDesktopPort = 6901 /// Whether a machine payload (`vm.create` / `vm.status` / `vm.base_open` @@ -5959,9 +5957,7 @@ struct CMUXCLI { // away from (the New Machine sheet) does not yank them back when it lands. let focus = try parseCloudVMFocusOption(focusOpt, command: "vm new") let detach = hasFlag(rem2, name: "--detach") || hasFlag(rem2, name: "-d") - // A bare `vm new` asks for a machine with a screen; `--base` (alias - // `--no-desktop`) is the explicit shell-only choice (#12239). - let requestedKind = try Self.parseExplicitCloudVMKindFlag(rem2, command: "vm new") + let machineKind = try Self.cloudVMCreateKind(rem2, command: "vm new") let (sizeOpt, rem3) = parseOption(rem2, name: "--size") let memoryMb: Int? if let sizeOpt { @@ -5978,18 +5974,16 @@ struct CMUXCLI { memoryMb = nil } let remaining = rem3.filter { !["--detach", "-d", "--desktop", "--base", "--no-desktop"].contains($0) } - // The kind is what the CLI asks for; the backend picks the image. A desktop + // The kind is what the CLI asks for; the backend picks the image. The // machine gets its screen streamed into a browser split beside the shell. - let machineKind = requestedKind ?? VMMachineKind.defaultKind let machineName = nameOpt?.trimmingCharacters(in: .whitespacesAndNewlines) if let unknown = remaining.first(where: { Self.isUnknownFlagToken($0, allowedShortFlags: ["-d"]) }) { throw CLIError(message: """ vm new: unknown flag '\(unknown)'. Known flags: - --desktop machine with a VNC screen (the default) - --base shell-only machine, no screen (alias --no-desktop) --size <4g|8g|16g|24g|32g|64g> + --desktop, --base \(String(localized: "cli.vm.help.legacyKindFlags", defaultValue: "accepted for older scripts; every machine has a screen")) --name