diff --git a/web/.env.example b/web/.env.example index 4a68c2e9e8af..4099026758d8 100644 --- a/web/.env.example +++ b/web/.env.example @@ -138,7 +138,7 @@ CMUX_VM_ALLOW_UNMANIFESTED_IMAGES= CMUX_VM_ALLOWED_ORIGINS= CMUX_VM_FREESTYLE_ENABLED=1 FREESTYLE_API_KEY= -FREESTYLE_SANDBOX_SNAPSHOT=sh-940ec3bc46224c019e5e8d9a97053293 +# The Cloud VM image comes from web/services/vms/images/manifest.json (defaultForKind); no env var selects it. # Cloud VM database. Local dev derives DATABASE_URL from CMUX_PORT. Staging/production use the # Vercel Marketplace AWS Aurora PostgreSQL OIDC/RDS IAM env set below. diff --git a/web/package.json b/web/package.json index 534941fba699..240c66fc9ecf 100644 --- a/web/package.json +++ b/web/package.json @@ -19,6 +19,9 @@ "cloud-vm:preflight": "bash scripts/cloud-vm/verify-migration-preflight.sh", "cloud-vm:smoke": "bun scripts/cloud-vm/smoke-vm-api.mjs", "cloud-vm:stress": "bun scripts/cloud-vm/stress-vm-api.mjs", + "devbox:bake:freestyle": "bun scripts/build-devbox-freestyle.ts", + "devbox:verify": "bun scripts/verify-devbox-image.ts", + "devbox:promote": "bun scripts/promote-devbox-image.ts", "db:check": "bunx drizzle-kit check --config drizzle.config.ts", "db:down": "bash scripts/db-local.sh down", "db:generate": "bunx drizzle-kit generate --config drizzle.config.ts", diff --git a/web/scripts/build-devbox-freestyle.ts b/web/scripts/build-devbox-freestyle.ts index 126389bf009a..b1476eace4fa 100644 --- a/web/scripts/build-devbox-freestyle.ts +++ b/web/scripts/build-devbox-freestyle.ts @@ -1,22 +1,45 @@ #!/usr/bin/env bun /** * Build the cmux Cloud devbox Freestyle snapshot on the public platform - * (freestyle@0.2.9; api.freestyle.sh) by replaying - * web/services/vms/images/devbox/Dockerfile as exec steps on a builder VM, - * mirroring chatmux infra/sandbox-images/build-freestyle.ts. - * The exec API has no COPY; small repo files travel as base64 embeds. + * (freestyle@0.2.x; api.freestyle.sh) on top of the `freestyle/ubuntu` base, + * plus the desktop layer (web/services/vms/images/devbox/desktop, ported + * from the retired Blaxel cmux-devbox image): an openbox/TigerVNC desktop + * with a tint2 dock, Ghostty, Chrome, Thunar, and noVNC on 6901. * * Usage: - * bun scripts/build-devbox-freestyle.ts + * bun scripts/build-devbox-freestyle.ts [--out ] + * [--replace-slug] [--no-desktop] [--keep-builder] * - * Auth: FREESTYLE_API_KEY (permanent key from the Freestyle dashboard), or - * FREESTYLE_STACK_ACCESS_TOKEN + FREESTYLE_TEAM_ID for interactive use - * (mint via `npx freestyle login`). FREESTYLE_API_URL overrides the edge. + * Prints the bake result as JSON (and writes it to --out); the LAST stdout + * line is `IMAGE_ID sh-…`. promote-devbox-image.ts drives this, verifies the + * snapshot, and records the id in the manifest. * - * Freestyle snapshot slugs are unique per account and creation does not - * reassign, so pass a fresh versioned slug per rebuild (cmux-devbox-); - * on a slug collision the snapshot falls back to slugless. Either way the - * immutable sh-… id in SNAPSHOT_RESULT is the real pointer to pin. + * Uses what the base already ships instead of replaying the container + * Dockerfile's toolchain: `freestyle/ubuntu` comes with Node LTS under nvm + * (symlinked into /usr/local/bin), Bun, Python 3.12, uv, Docker (running from + * boot), git, jq, tmux, and an `ubuntu` user (uid 1000, passwordless sudo, + * the API's default exec user and the SSH default). The bake adds the + * chatmux-devbox devtools, gh, Chrome + cua-driver, the pinned coding agents + * (`npm install -g` on the base's Node, so the exact Dockerfile pins replace + * the base's copies), the ble.sh devshell, the agent-config generator, the + * login banner, and the desktop. No mise, no extra users: `ubuntu` is the + * work user for terminals, agents, SSH, and the desktop session. + * + * Auth: FREESTYLE_API_KEY (permanent key from the Freestyle dashboard or + * `freestyle tokens create`), or FREESTYLE_STACK_ACCESS_TOKEN + + * FREESTYLE_TEAM_ID for interactive use (mint via `npx freestyle login`). + * FREESTYLE_API_URL overrides the edge. + * + * Snapshot slugs are unique per account and reassignable + * (freestyle.vms.snapshots.update); the immutable sh-… id is still the + * pointer the manifest pins. With --replace-slug the bake moves a taken slug + * onto the new snapshot (the old holder keeps its data under its id); + * without it a collision leaves the new snapshot slugless. + * + * Builder VM: freestyle/ubuntu (4 vCPU / 8 GiB / 32 GB). VMs always boot at + * their snapshot's size and resizing is grow-only, so this is the shape every + * cmux Cloud machine gets. CMUX_FREESTYLE_BUILDER_SNAPSHOT overrides it. + * Outbound-only firewall; deleted whatever happens (unless --keep-builder). * * Daemon contract: the session daemon is cmux-tui, same as every other * provider (docs/cloud-cmux-tui-daemon.md). No binary is baked: the @@ -25,18 +48,23 @@ * installs the pinned build at create time. The unit binds the listener * dual-stack (CMUX_TUI_REMOTE_WS_BIND=[::]:1337) because the driver * (web/services/vms/drivers/freestyle.ts) routes attaches to the VM's stable - * public IPv6. + * public IPv6. The daemon still runs as root until the driver adopts the + * ubuntu user for sessions. * - * Builder VM: freestyle/ubuntu-sm, outbound-only firewall, deleted whatever - * happens. + * Desktop contract (desktop/start-vnc.sh): RFB 5901 loopback, noVNC 6901, + * run as `ubuntu` by the cmux-desktop systemd unit. */ import { Freestyle } from "freestyle"; import { fileURLToPath } from "node:url"; import { + DEVBOX_GHOSTTY_DEB_URL, bakeMetadata, bakePreflight, devboxAgentPins, - fileBase64, + devboxCuaDriverVersion, + devboxFileBytes, + emitBakeResult, + hasFlag, manifestEntrySkeleton, } from "./devbox-image-common"; @@ -52,35 +80,51 @@ const fs = (() => { const slug = process.argv[2]; if (!slug || slug.startsWith("--")) { - throw new Error("usage: bun scripts/build-devbox-freestyle.ts "); + throw new Error("usage: bun scripts/build-devbox-freestyle.ts [--out ] [--replace-slug] [--no-desktop] [--keep-builder]"); } +if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(slug) || slug.includes("--")) { + throw new Error(`snapshot slug ${slug} must be 1–63 chars of [a-z0-9-] with no leading, trailing, or repeated hyphens`); +} +const withDesktop = !hasFlag("--no-desktop"); +const keepBuilder = hasFlag("--keep-builder"); +const replaceSlug = hasFlag("--replace-slug"); -const preflight = bakePreflight(); +const preflight = bakePreflight({ desktop: withDesktop }); // The exec API caps timeoutMs at 300000 (5 minutes per step). const STEP_TIMEOUT_MS = 300_000; -// Per-exec env (the API replays it into every step): the Dockerfile's -// ENV block, PATH literal included. +// Per-exec env (the API replays it into every step). The base's login PATH +// puts the nvm bin dir first; /usr/local/bin carries the same tools as +// symlinks, so this is what a non-login shell sees too. const BUILD_ENV = { - MISE_DATA_DIR: "/opt/mise", - MISE_CACHE_DIR: "/opt/mise/cache", - MISE_GLOBAL_CONFIG_FILE: "/etc/mise/config.toml", - PATH: "/opt/mise/shims:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", DEBIAN_FRONTEND: "noninteractive", LANG: "C.UTF-8", }; -const builderSnapshot = process.env.CMUX_FREESTYLE_BUILDER_SNAPSHOT?.trim() || "freestyle/ubuntu-sm"; +/** The work user: the base's uid-1000 account, the API and SSH default. */ +const WORK_USER = "ubuntu"; +const WORK_HOME = `/home/${WORK_USER}`; + +const builderSnapshot = process.env.CMUX_FREESTYLE_BUILDER_SNAPSHOT?.trim() || "freestyle/ubuntu"; const { vm, vmId } = await fs.vms.create({ snapshotId: builderSnapshot, - displayName: "cmux-devbox-builder", + displayName: `cmux-devbox-builder ${slug}`, firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, }); -console.log(`builder VM ${vmId} (base ${builderSnapshot})`); +console.log(`builder VM ${vmId} (base ${builderSnapshot}, desktop=${withDesktop})`); + +async function deleteBuilder(): Promise { + if (keepBuilder) { + console.log(`--keep-builder: leaving ${vmId} running`); + return; + } + await vm.delete().catch((error: unknown) => console.warn(`builder delete failed: ${String(error)}`)); +} // Freestyle guest exec starts with an EMPTY $HOME; restore it before every -// step (npm, mise, and the installers all read it). +// step (npm and the installers all read it). const HOME_PREFIX = 'export HOME="${HOME:-$(getent passwd $(id -u) | cut -d: -f6)}"'; async function step(label: string, command: string): Promise { @@ -89,8 +133,8 @@ async function step(label: string, command: string): Promise { command: `${HOME_PREFIX} && ${command}`, env: BUILD_ENV, timeoutMs: STEP_TIMEOUT_MS, - // The 0.2 API's default guest user is uid 1000, not root. Every build step - // writes to /opt, /usr/local and /etc, so the bake must run as root. + // The 0.2 API's default guest user is uid 1000 (ubuntu). Every build step + // writes to /usr/local and /etc, so the bake runs as root. linuxUser: "root", }); const secs = ((Date.now() - t0) / 1000).toFixed(1); @@ -99,151 +143,277 @@ async function step(label: string, command: string): Promise { console.error(`STEP FAILED [${label}] status=${exitCode} (${secs}s)`); console.error("stdout:", (r.stdout ?? "").slice(-3000)); console.error("stderr:", (r.stderr ?? "").slice(-3000)); - await vm.delete().catch(() => {}); + await deleteBuilder(); process.exit(1); } const tail = (r.stdout ?? "").trim().split("\n").slice(-3).join(" | "); console.log(`ok [${label}] ${secs}s :: ${tail}`); } -const installFile = (source: string, target: string): string => - `echo '${fileBase64(source)}' | base64 -d > ${target}`; - -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 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", -); - -await step( - "gh-cli", - "curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" > /etc/apt/sources.list.d/github-cli.list && apt-get update -q && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/* && gh --version", -); - -await step( - "mise", - "curl -fsSL https://mise.run | MISE_INSTALL_PATH=/usr/local/bin/mise sh && mkdir -p /etc/mise /etc/profile.d && echo 'export MISE_DATA_DIR=/opt/mise' > /etc/profile.d/mise.sh && echo 'export MISE_CACHE_DIR=/opt/mise/cache' >> /etc/profile.d/mise.sh && echo 'export MISE_GLOBAL_CONFIG_FILE=/etc/mise/config.toml' >> /etc/profile.d/mise.sh && echo 'export PATH=\"/opt/mise/shims:$PATH\"' >> /etc/profile.d/mise.sh && mise --version", -); - -await step( - "toolchain", - "mise use -g node@lts python@3.12 bun@latest && mise reshim && node --version && npm --version && python --version && bun --version", -); - -await step( - "uv", - "curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/bin INSTALLER_NO_MODIFY_PATH=1 sh && uv --version", -); - -await step( - "media-apt", - "apt-get update -q && apt-get install -y --no-install-recommends ffmpeg xvfb xauth x11-utils xdotool fonts-dejavu-core fonts-liberation && rm -rf /var/lib/apt/lists/* && command -v Xvfb && command -v xdpyinfo && command -v xdotool", -); - -await step( - "chrome", - "curl -fsSL -o /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && apt-get update -q && apt-get install -y --no-install-recommends /tmp/chrome.deb && rm -f /tmp/chrome.deb && rm -rf /var/lib/apt/lists/* && google-chrome-stable --version", -); - -await step( - "chrome-policy", - `mkdir -p /etc/opt/chrome/policies/managed && ${installFile("chrome-managed-policy.json", "/etc/opt/chrome/policies/managed/cmux.json")} && jq -e '.DefaultSearchProviderSearchURL | test("duckduckgo")' /etc/opt/chrome/policies/managed/cmux.json && echo 'export AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable' > /etc/profile.d/cmux-media.sh`, -); - -await step( - "cua-driver", - "curl -fsSL https://cua.ai/driver/install.sh -o /tmp/cua-install.sh && CUA_DRIVER_RS_HOME=/opt/cua-driver CUA_DRIVER_RS_VERSION=0.23.2 CUA_DRIVER_BIN_DIR=/usr/local/bin CUA_DRIVER_NO_MODIFY_PATH=1 bash /tmp/cua-install.sh && rm -f /tmp/cua-install.sh && chmod -R a+rX /opt/cua-driver && cua-driver --version", -); +/** + * Ship a checked-in template file into the guest. The fs API writes as root, + * atomically and sha256-verified, so no base64 smuggling through exec; the + * mode is explicit because the API defaults new files to 0600. + */ +async function put(source: string, target: string, mode = 0o644): Promise { + const t0 = Date.now(); + await vm.fs.writeFile(target, devboxFileBytes(source), { mode }); + console.log(`put ${source} -> ${target} (${(mode).toString(8)}) ${((Date.now() - t0) / 1000).toFixed(1)}s`); +} +// The devshell chain goes into the per-user rc files, not /etc/bash.bashrc: +// bash sources the system file BEFORE ~/.bashrc, and Ubuntu's stock ~/.bashrc +// sets its own PS1, which would clobber the cmux prompt. Per-user only also +// loads ble.sh exactly once per shell (the container Dockerfile appends to +// both because its homes are shadowed by volumes at runtime). +const rcFiles = ["/etc/skel/.bashrc", "/root/.bashrc", `${WORK_HOME}/.bashrc`]; const pins = devboxAgentPins(); -await step( - "agents", - `npm install -g --foreground-scripts ${pins.map((pin) => `'${pin.spec}'`).join(" ")} && mise reshim && ${pins.map((pin) => `${pin.binary} --version`).join(" && ")}`, -); - -await step( - "claude-managed-settings", - `mkdir -p /etc/claude-code && echo '{ "cleanupPeriodDays": 99999 }' > /etc/claude-code/managed-settings.json && node -e 'JSON.parse(require("fs").readFileSync("/etc/claude-code/managed-settings.json","utf8"))'`, -); - -// devshell replays the Dockerfile devshell + ble.sh tput cache bake (same -// echo-fed seed shells and test -s guards; see ../services/vms/images/devbox/Dockerfile). -await step( - "devshell", - `curl -fsSL https://github.com/akinomyoga/ble.sh/releases/download/nightly/ble-nightly.tar.xz -o /tmp/ble.tar.xz && tar xJf /tmp/ble.tar.xz -C /tmp && rm -rf /usr/local/share/blesh && mv /tmp/ble-nightly /usr/local/share/blesh && rm -f /tmp/ble.tar.xz && test -f /usr/local/share/blesh/ble.sh && mkdir -p /etc/cmux /etc/skel && ${installFile("cmux-bashrc", "/etc/cmux/bashrc")} && bash -n /etc/cmux/bashrc && ${installFile("seed-history", "/etc/cmux/seed-history")} && echo '[ -f /etc/cmux/bashrc ] && . /etc/cmux/bashrc' >> /etc/bash.bashrc && echo '[ -f /etc/cmux/bashrc ] && . /etc/cmux/bashrc' >> /etc/skel/.bashrc && echo '[ -f /etc/cmux/bashrc ] && . /etc/cmux/bashrc' >> /root/.bashrc && echo 'set -g default-shell /bin/bash' >> /etc/tmux.conf && bash -ic 'head -2 $HOME/.bash_history' && mkdir -p /etc/cmux/blesh-cache-seed && for term in xterm-256color screen-256color tmux-256color linux; do echo exit | TERM="$term" HOME=/tmp/blesh-seed-home XDG_CACHE_HOME=/etc/cmux/blesh-cache-seed script -qec 'bash -i' /dev/null >/dev/null 2>&1 || true; done && rm -rf /tmp/blesh-seed-home && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.xterm-256color && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.screen-256color && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.tmux-256color && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.linux && mkdir -p /usr/local/share/blesh/cache.d/0 /usr/local/share/blesh/cache.d/1000 && chmod a+rwxt /usr/local/share/blesh/cache.d && cp /etc/cmux/blesh-cache-seed/blesh/*/term.* /usr/local/share/blesh/cache.d/0/ && cp /etc/cmux/blesh-cache-seed/blesh/*/term.* /usr/local/share/blesh/cache.d/1000/ && chmod 700 /usr/local/share/blesh/cache.d/0 /usr/local/share/blesh/cache.d/1000 && chown -R 1000:1000 /usr/local/share/blesh/cache.d/1000`, -); - -await step( - "agent-config", - `${installFile("agent-config.sh", "/etc/cmux/agent-config.sh")} && bash -n /etc/cmux/agent-config.sh && echo '[ -f /etc/cmux/agent-config.sh ] && . /etc/cmux/agent-config.sh' > /etc/profile.d/cmux-agents.sh && echo '[ -f /etc/cmux/agent-config.sh ] && . /etc/cmux/agent-config.sh' >> /etc/bash.bashrc && echo '[ -f /etc/cmux/agent-config.sh ] && . /etc/cmux/agent-config.sh' >> /etc/skel/.bashrc && echo '[ -f /etc/cmux/agent-config.sh ] && . /etc/cmux/agent-config.sh' >> /root/.bashrc && mkdir -p /tmp/agent-config-check && env HOME=/tmp/agent-config-check OPENAI_BASE_URL=https://example.invalid/v1 OPENAI_API_KEY=crt_check CMUX_CODEROUTER_URL=https://example.invalid bash -lc 'true' && grep -q 'model_provider = "cmux"' /tmp/agent-config-check/.codex/config.toml && grep -q 'wire_api = "responses"' /tmp/agent-config-check/.codex/config.toml && grep -q 'supports_websockets = false' /tmp/agent-config-check/.codex/config.toml && grep -q "export OPENAI_API_KEY='crt_check'" /tmp/agent-config-check/.config/cmux/model-plane.env && [ "$(stat -c %a /tmp/agent-config-check/.config/cmux/model-plane.env)" = "600" ] && grep -qF '"x-coderouter-route-token": "$OPENAI_API_KEY"' /tmp/agent-config-check/.pi/agent/models.json && ! grep -q crt_check /tmp/agent-config-check/.pi/agent/models.json && test ! -e /tmp/agent-config-check/.config/opencode/opencode.json && rm -rf /tmp/agent-config-check && test ! -e /root/.codex/config.toml && test ! -e /root/.pi/agent/models.json && test ! -e /root/.config/opencode/opencode.json`, -); - -// The cmux-tui daemon supervisor + its systemd unit. No binary is baked; the -// unit's supervisor loop waits for the driver-installed build (see the header). -const service = [ - "[Unit]", - "Description=cmux-tui session daemon supervisor", - "After=network.target", - "", - "[Service]", - "Type=simple", - "User=root", - // Freestyle machines are reached at their stable public IPv6, so the - // daemon listens dual-stack ([::] accepts IPv4 too). cmux-devbox-boot - // defaults to 0.0.0.0 for the container providers, whose runtimes may have - // IPv6 disabled entirely. - "Environment=CMUX_TUI_REMOTE_WS_BIND=[::]:1337", - "ExecStart=/usr/local/bin/cmux-devbox-boot", - "Restart=always", - "RestartSec=2", - "", - "[Install]", - "WantedBy=multi-user.target", -].join("\n"); -const serviceB64 = Buffer.from(service, "utf8").toString("base64"); -await step( - "cmux-tui-daemon-unit", - `${installFile("cmux-devbox-boot", "/usr/local/bin/cmux-devbox-boot")} && chmod 0755 /usr/local/bin/cmux-devbox-boot && sh -n /usr/local/bin/cmux-devbox-boot && echo '${serviceB64}' | base64 -d > /etc/systemd/system/cmux-tui-daemon.service && mkdir -p /etc/systemd/system/multi-user.target.wants && ln -sf /etc/systemd/system/cmux-tui-daemon.service /etc/systemd/system/multi-user.target.wants/cmux-tui-daemon.service && systemctl daemon-reload && systemctl enable cmux-tui-daemon && systemctl restart cmux-tui-daemon && systemctl is-active cmux-tui-daemon`, -); - -await step( - "ghost-text-smoke", - "tmux new-session -d -s ghost -x 100 -y 24 && sleep 2 && tmux send-keys -t ghost cl && sleep 2 && tmux capture-pane -pt ghost | grep -o 'claude --dangerously-skip-permissions' | head -1; rc=$?; tmux kill-session -t ghost 2>/dev/null; tmux kill-server 2>/dev/null; test $rc -eq 0", -); - -await step("clean", "rm -rf /var/lib/apt/lists/* /root/.npm/_cacache 2>/dev/null; sync; true"); - -// Snapshot slugs are unique per account and creation does not reassign; on a -// collision fall back to a slugless snapshot (the sh-… id is the pointer). -const snap = await vm - .snapshot({ slug, displayName: "cmux devbox (Dockerfile mirror)" }) - .catch(async (error: unknown) => { - console.warn(`slug taken (${String(error).slice(0, 120)}); snapshotting without slug`); - return vm.snapshot({ displayName: "cmux devbox (Dockerfile mirror)" }); - }); -console.log("SNAPSHOT_RESULT", JSON.stringify(snap)); -await vm.delete(); -console.log("builder deleted"); +/** + * A real interactive shell as the work user under a tmux pty (ble.sh refuses + * `bash -c` and ttyless shells; `script` with a closed stdin is not a faithful + * login either). Fails if the pane shows anything ble.sh or bash complained + * about, the Ubuntu legal text, or (on the second run, once caches are seeded) + * the tput-cache notice, and requires the cmux prompt. Runs as the work user + * so nothing it creates is root-owned. + */ +const interactiveShellProbe = (run: number): string => + `sudo -n -u ${WORK_USER} env -i HOME=${WORK_HOME} USER=${WORK_USER} TERM=xterm-256color PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash -c 'tmux -L probe${run} new-session -d -s login -x 120 -y 30 && sleep 3 && pane="$(tmux -L probe${run} capture-pane -pt login)"; tmux -L probe${run} kill-server 2>/dev/null; printf "%s\\n" "$pane" | grep -iE "ble\\.sh|bleopt|ble-face|denied|not found|WARRANTY${run > 1 ? "|updating tput" : ""}" && { printf "%s\\n" "$pane"; exit 1; }; printf "%s\\n" "$pane" | grep -q "λ" || { printf "%s\\n" "$pane"; echo "no cmux prompt"; exit 1; }'`; + +try { + await step( + "base-inventory", + `id ${WORK_USER} && [ "$(id -u ${WORK_USER})" = 1000 ] && sudo -n -u ${WORK_USER} sudo -n true && node --version && npm --version && bun --version && python3 --version && uv --version && docker --version && test -L /usr/local/bin/node && readlink /usr/local/bin/node | grep -q /usr/local/nvm/ && echo base-ok`, + ); + + 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", + ); + + await step( + "gh-cli", + "curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg -o /usr/share/keyrings/githubcli-archive-keyring.gpg && chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && echo \"deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main\" > /etc/apt/sources.list.d/github-cli.list && apt-get update -q && apt-get install -y --no-install-recommends gh && rm -rf /var/lib/apt/lists/* && gh --version", + ); + + await step( + "media-apt", + "apt-get update -q && apt-get install -y --no-install-recommends ffmpeg xvfb xauth x11-utils xdotool fonts-dejavu-core fonts-liberation && rm -rf /var/lib/apt/lists/* && command -v Xvfb && command -v xdpyinfo && command -v xdotool", + ); + + await step( + "chrome", + "curl -fsSL -o /tmp/chrome.deb https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb && apt-get update -q && apt-get install -y --no-install-recommends /tmp/chrome.deb && rm -f /tmp/chrome.deb && rm -rf /var/lib/apt/lists/* && google-chrome-stable --version", + ); + + await step("chrome-policy-dir", "mkdir -p /etc/opt/chrome/policies/managed"); + await put("chrome-managed-policy.json", "/etc/opt/chrome/policies/managed/cmux.json"); + await step( + "chrome-policy", + "jq -e '.DefaultSearchProviderSearchURL | test(\"duckduckgo\")' /etc/opt/chrome/policies/managed/cmux.json && echo 'export AGENT_BROWSER_EXECUTABLE_PATH=/usr/bin/google-chrome-stable' > /etc/profile.d/cmux-media.sh", + ); + const cuaVersion = devboxCuaDriverVersion(); + await step( + "cua-driver", + `curl -fsSL https://cua.ai/driver/install.sh -o /tmp/cua-install.sh && CUA_DRIVER_RS_HOME=/opt/cua-driver CUA_DRIVER_RS_VERSION=${cuaVersion} CUA_DRIVER_BIN_DIR=/usr/local/bin CUA_DRIVER_NO_MODIFY_PATH=1 bash /tmp/cua-install.sh && rm -f /tmp/cua-install.sh && chmod -R a+rX /opt/cua-driver && cua-driver --version`, + ); + + // Pinned coding agents on the base's Node: the exact Dockerfile pins + // replace the base's own copies of claude/codex/opencode in nvm's global + // node_modules, and every agent bin is symlinked into /usr/local/bin the + // way the base does it, so non-login shells (daemon panes) find them too. + await step( + "agents", + // The pin probes run AS the work user: an agent run as root with + // HOME=/home/ubuntu leaves root-owned state dirs behind that break + // ble.sh for every later login. + `npm install -g --foreground-scripts ${pins.map((pin) => `'${pin.spec}'`).join(" ")} && nvm_bin="$(dirname "$(readlink -f /usr/local/bin/node)")" && ${pins.map((pin) => `ln -sfn "$nvm_bin/${pin.binary}" /usr/local/bin/${pin.binary}`).join(" && ")} && ${pins.map((pin) => `${pin.binary} --version`).join(" && ")} && ${pins.map((pin) => `sudo -n -u ${WORK_USER} env -i HOME=${WORK_HOME} USER=${WORK_USER} TERM=xterm bash -lc '${pin.binary} --version' | grep -F '${pin.version}'`).join(" && ")} && echo agents-pinned`, + ); + + await step( + "claude-managed-settings", + `mkdir -p /etc/claude-code && echo '{ "cleanupPeriodDays": 99999 }' > /etc/claude-code/managed-settings.json && node -e 'JSON.parse(require("fs").readFileSync("/etc/claude-code/managed-settings.json","utf8"))'`, + ); + + // devshell replays the Dockerfile devshell + ble.sh tput cache bake (same + // echo-fed seed shells and test -s guards; see ../services/vms/images/devbox/Dockerfile). + // Cache seeds cover root and the ubuntu work user (uid 1000). + await step("cmux-etc", "mkdir -p /etc/cmux /etc/skel"); + await put("cmux-bashrc", "/etc/cmux/bashrc"); + await put("seed-history", "/etc/cmux/seed-history"); + await step( + "devshell", + `curl -fsSL https://github.com/akinomyoga/ble.sh/releases/download/nightly/ble-nightly.tar.xz -o /tmp/ble.tar.xz && tar xJf /tmp/ble.tar.xz -C /tmp && rm -rf /usr/local/share/blesh && mv /tmp/ble-nightly /usr/local/share/blesh && rm -f /tmp/ble.tar.xz && test -f /usr/local/share/blesh/ble.sh && bash -n /etc/cmux/bashrc && ${rcFiles.map((rc) => `echo '[ -f /etc/cmux/bashrc ] && . /etc/cmux/bashrc' >> ${rc}`).join(" && ")} && echo 'set -g default-shell /bin/bash' >> /etc/tmux.conf && bash -ic 'head -2 $HOME/.bash_history' && mkdir -p /etc/cmux/blesh-cache-seed /tmp/blesh-seed-home && echo '[ -f /etc/cmux/bashrc ] && . /etc/cmux/bashrc' > /tmp/blesh-seed-home/.bashrc && for term in xterm-256color screen-256color tmux-256color linux xterm-ghostty; do echo exit | TERM="$term" HOME=/tmp/blesh-seed-home XDG_CACHE_HOME=/etc/cmux/blesh-cache-seed script -qec 'bash -i' /dev/null >/dev/null 2>&1 || true; done && rm -rf /tmp/blesh-seed-home && chmod -R a+rX /etc/cmux/blesh-cache-seed && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.xterm-256color && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.screen-256color && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.tmux-256color && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.linux && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.xterm-ghostty && mkdir -p /usr/local/share/blesh/cache.d/0 /usr/local/share/blesh/cache.d/1000 && chmod a+rwxt /usr/local/share/blesh/cache.d && cp /etc/cmux/blesh-cache-seed/blesh/*/term.* /usr/local/share/blesh/cache.d/0/ && cp /etc/cmux/blesh-cache-seed/blesh/*/term.* /usr/local/share/blesh/cache.d/1000/ && chmod 700 /usr/local/share/blesh/cache.d/0 /usr/local/share/blesh/cache.d/1000 && chown -R 1000:1000 /usr/local/share/blesh/cache.d/1000`, + ); + + await put("agent-config.sh", "/etc/cmux/agent-config.sh"); + await step( + "agent-config", + `bash -n /etc/cmux/agent-config.sh && echo '[ -f /etc/cmux/agent-config.sh ] && . /etc/cmux/agent-config.sh' > /etc/profile.d/cmux-agents.sh && ${rcFiles.map((rc) => `echo '[ -f /etc/cmux/agent-config.sh ] && . /etc/cmux/agent-config.sh' >> ${rc}`).join(" && ")} && mkdir -p /tmp/agent-config-check && env HOME=/tmp/agent-config-check OPENAI_BASE_URL=https://example.invalid/v1 OPENAI_API_KEY=crt_check CMUX_CODEROUTER_URL=https://example.invalid bash -lc 'true' && grep -q 'model_provider = "cmux"' /tmp/agent-config-check/.codex/config.toml && grep -q 'wire_api = "responses"' /tmp/agent-config-check/.codex/config.toml && grep -q 'supports_websockets = false' /tmp/agent-config-check/.codex/config.toml && grep -q "export OPENAI_API_KEY='crt_check'" /tmp/agent-config-check/.config/cmux/model-plane.env && [ "$(stat -c %a /tmp/agent-config-check/.config/cmux/model-plane.env)" = "600" ] && grep -qF '"x-coderouter-route-token": "$OPENAI_API_KEY"' /tmp/agent-config-check/.pi/agent/models.json && ! grep -q crt_check /tmp/agent-config-check/.pi/agent/models.json && test ! -e /tmp/agent-config-check/.config/opencode/opencode.json && rm -rf /tmp/agent-config-check && test ! -e /root/.codex/config.toml && test ! -e /root/.pi/agent/models.json && test ! -e /root/.config/opencode/opencode.json && test ! -e ${WORK_HOME}/.codex/config.toml`, + ); + + // Login banner: pam_motd renders /etc/update-motd.d on SSH logins. The + // stock Ubuntu scripts stay but go silent, Freestyle's static /etc/motd is + // emptied, and the versions the banner prints are written once here (the + // agent line straight from the Dockerfile pins) so login never runs a tool. + await put("cmux-motd", "/etc/update-motd.d/00-cmux", 0o755); + await step( + "motd", + `sh -n /etc/update-motd.d/00-cmux && for f in /etc/update-motd.d/*; do [ "$f" = /etc/update-motd.d/00-cmux ] || chmod -x "$f"; done && : > /etc/motd && echo '${pins.filter((pin) => pin.binary !== "agent-browser").map((pin) => `${pin.binary} ${pin.version}`).join(" · ")}' > /etc/cmux/tool-versions && echo "node $(node --version) · python $(python3 --version | awk '{print $2}') · bun $(bun --version) · uv $(uv --version | awk '{print $2}') · gh $(gh --version | head -1 | awk '{print $3}') · docker $(docker --version 2>/dev/null | awk '{print $3}' | tr -d ,)" >> /etc/cmux/tool-versions && cat /etc/cmux/tool-versions && run-parts /etc/update-motd.d | grep -q 'persistent cloud VM' && ! run-parts /etc/update-motd.d | grep -qi 'ubuntu.com' && echo motd-ok`, + ); + + if (withDesktop) { + // Desktop + media stack (Blaxel cmux-devbox apt list): TigerVNC, openbox, + // tint2, Thunar, feh, noVNC + websockify, and the GL/Vulkan/xkb libraries + // Ghostty and Chrome render with under Xvnc. + await step( + "desktop-apt", + "apt-get update -q && apt-get install -y --no-install-recommends tigervnc-standalone-server tigervnc-tools openbox tint2 thunar feh novnc websockify at-spi2-core dbus-x11 x11-xserver-utils xdg-utils adwaita-icon-theme libgl1-mesa-dri mesa-vulkan-drivers libvulkan1 libxkbcommon0 libxkbcommon-x11-0 netcat-openbsd && rm -rf /var/lib/apt/lists/* && { [ -e /usr/share/novnc/index.html ] || ln -s vnc.html /usr/share/novnc/index.html; } && command -v Xtigervnc && command -v websockify && command -v openbox && command -v tint2", + ); + + // Ghostty: pinned community .deb for Ubuntu 24.04 (no upstream .deb + // exists). libgl1-mesa-dri above is the software GL its renderer uses. + await step( + "ghostty", + `curl -fsSL -o /tmp/ghostty.deb ${DEVBOX_GHOSTTY_DEB_URL} && apt-get update -q && apt-get install -y --no-install-recommends /tmp/ghostty.deb && rm -rf /var/lib/apt/lists/* /tmp/ghostty.deb && ghostty +version | head -1`, + ); + + // Dock launchers and icons live under /etc/cmux so the dock never depends + // on a distro's /usr/share/applications or icon-theme layout. + await step("desktop-dirs", "mkdir -p /etc/cmux/apps /etc/cmux/icons /usr/share/backgrounds/cmux"); + await put("desktop/google-chrome-cmux.desktop", "/etc/cmux/apps/google-chrome-cmux.desktop"); + await put("desktop/thunar-cmux.desktop", "/etc/cmux/apps/thunar-cmux.desktop"); + await put("desktop/ghostty-cmux.desktop", "/etc/cmux/apps/ghostty-cmux.desktop"); + await put("desktop/tint2rc", "/etc/cmux/tint2rc"); + await put("desktop/wallpaper.jpg", "/usr/share/backgrounds/cmux/wallpaper.jpg"); + await put("desktop/start-vnc.sh", "/usr/local/bin/start-vnc.sh", 0o755); + await put("desktop/cmux-desktop-boot", "/usr/local/bin/cmux-desktop-boot", 0o755); + await put("desktop/cmux-desktop.service", "/etc/systemd/system/cmux-desktop.service"); + await step( + "desktop-icons", + "cp /opt/google/chrome/product_logo_128.png /etc/cmux/icons/google-chrome.png && cp \"$(find /usr/share/icons -name 'org.xfce.thunar.png' -path '*128*' | head -1)\" /etc/cmux/icons/thunar.png && cp \"$(find /usr/share/icons -name 'com.mitchellh.ghostty.png' -path '*128*' | head -1)\" /etc/cmux/icons/ghostty.png && test -s /etc/cmux/icons/thunar.png && test -s /etc/cmux/icons/ghostty.png && test -s /etc/cmux/icons/google-chrome.png", + ); + + // Bring the desktop up under systemd as the work user and prove the + // contract ports answer. The "First Run" marker pre-accepts Chrome's + // first-run/ToS dialog (cmux-desktop-boot re-asserts it on every boot). + await step( + "desktop-unit", + `bash -n /usr/local/bin/start-vnc.sh && sh -n /usr/local/bin/cmux-desktop-boot && grep -q '^User=${WORK_USER}$' /etc/systemd/system/cmux-desktop.service && mkdir -p ${WORK_HOME}/.config/google-chrome && touch '${WORK_HOME}/.config/google-chrome/First Run' && chown -R ${WORK_USER}:${WORK_USER} ${WORK_HOME}/.config && systemctl daemon-reload && systemctl enable --now cmux-desktop && for i in $(seq 1 60); do ss -tln | grep -q ':6901 ' && ss -tln | grep -q ':5901 ' && break; sleep 1; done && ss -tln | grep -q ':5901 ' && ss -tln | grep -q ':6901 ' && curl -fsS http://127.0.0.1:6901/ | grep -qi novnc && pgrep -u ${WORK_USER} -x openbox >/dev/null && pgrep -u ${WORK_USER} -x tint2 >/dev/null && systemctl is-active cmux-desktop && runuser -u ${WORK_USER} -- env DISPLAY=:1 xdpyinfo | grep dimensions`, + ); + } + + // The cmux-tui daemon supervisor + its systemd unit. No binary is baked; the + // unit's supervisor loop waits for the driver-installed build (see the header). + const service = [ + "[Unit]", + "Description=cmux-tui session daemon supervisor", + "After=network.target", + "", + "[Service]", + "Type=simple", + "User=root", + // Freestyle machines are reached at their stable public IPv6, so the + // daemon listens dual-stack ([::] accepts IPv4 too). cmux-devbox-boot + // defaults to 0.0.0.0 for the container providers, whose runtimes may have + // IPv6 disabled entirely. + "Environment=CMUX_TUI_REMOTE_WS_BIND=[::]:1337", + // Pane shells inherit this PATH; /usr/local/bin carries the base's Node + // and every pinned agent as symlinks, so no login shell is needed. + "Environment=PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "ExecStart=/usr/local/bin/cmux-devbox-boot", + "Restart=always", + "RestartSec=2", + "", + "[Install]", + "WantedBy=multi-user.target", + ].join("\n"); + await put("cmux-devbox-boot", "/usr/local/bin/cmux-devbox-boot", 0o755); + await vm.fs.writeFile("/etc/systemd/system/cmux-tui-daemon.service", `${service}\n`, { mode: 0o644 }); + await step( + "cmux-tui-daemon-unit", + "sh -n /usr/local/bin/cmux-devbox-boot && mkdir -p /etc/systemd/system/multi-user.target.wants && ln -sf /etc/systemd/system/cmux-tui-daemon.service /etc/systemd/system/multi-user.target.wants/cmux-tui-daemon.service && systemctl daemon-reload && systemctl enable cmux-tui-daemon && systemctl restart cmux-tui-daemon && systemctl is-active cmux-tui-daemon", + ); + + await step( + "ghost-text-smoke", + "tmux new-session -d -s ghost -x 100 -y 24 && sleep 2 && tmux send-keys -t ghost cl && sleep 2 && tmux capture-pane -pt ghost | grep -o 'claude --dangerously-skip-permissions' | head -1; rc=$?; tmux kill-session -t ghost 2>/dev/null; tmux kill-server 2>/dev/null; test $rc -eq 0", + ); + + // Home hygiene, after every layer that could have touched the work user's + // home: ble.sh needs a writable state dir (XDG state when it already + // exists, else /state.d/, which must be world-writable-sticky + // like cache.d), nothing in the home may be root-owned, and Ubuntu's + // pam_motd prints /etc/legal on every login (twice: sshd lists pam_motd + // twice) until ~/.cache/motd.legal-displayed exists. Then prove two real + // interactive logins as the work user are silent and ghost text works. + await step( + "home-hygiene", + `mkdir -p /usr/local/share/blesh/state.d && chmod a+rwxt /usr/local/share/blesh/state.d && for h in ${WORK_HOME} /root /etc/skel; do mkdir -p "$h/.cache" "$h/.local/state" && touch "$h/.cache/motd.legal-displayed"; done && chown -R ${WORK_USER}:${WORK_USER} ${WORK_HOME} && [ "$(find ${WORK_HOME} -not -user ${WORK_USER} | wc -l)" = 0 ] && ${interactiveShellProbe(1)} && ${interactiveShellProbe(2)} && sudo -n -u ${WORK_USER} env -i HOME=${WORK_HOME} USER=${WORK_USER} TERM=xterm-256color bash -c 'tmux -L bake new-session -d -s ghost -x 100 -y 24 && sleep 2 && tmux -L bake send-keys -t ghost cl && sleep 2 && tmux -L bake capture-pane -pt ghost | grep -o "claude --dangerously-skip-permissions" | head -1; rc=$?; tmux -L bake kill-server 2>/dev/null; exit $rc' && [ "$(find ${WORK_HOME} -not -user ${WORK_USER} | wc -l)" = 0 ] && echo home-hygiene-ok`, + ); + + // Stamp last: its presence tells the driver and the verifier every layer + // above baked successfully, and which layers the image carries. + await step( + "image-stamp", + `mkdir -p /etc/cmux && echo "cmux-devbox ${preflight.epoch}${withDesktop ? " desktop" : ""}" > /etc/cmux/image-stamp && cat /etc/cmux/image-stamp`, + ); + + await step("clean", `rm -rf /var/lib/apt/lists/* /root/.npm/_cacache ${WORK_HOME}/.npm/_cacache 2>/dev/null; sync; true`); +} catch (error) { + console.error(`bake failed: ${String(error)}`); + await deleteBuilder(); + process.exit(1); +} + +// Snapshot slugless first (the sh-… id is the pointer), then attach the slug. +// A slug already held by another snapshot only moves with --replace-slug. +const displayName = `cmux devbox ${slug} (epoch ${preflight.epoch}, ${preflight.sha.slice(0, 10)})`; +const snap = await vm.snapshot({ displayName }); const snapshotId = snap.snapshotId; +console.log("SNAPSHOT_RESULT", JSON.stringify(snap)); if (!snapshotId) { + await deleteBuilder(); throw new Error("Freestyle snapshot response carried no snapshot id; do not pin this bake"); } +await deleteBuilder(); +console.log(keepBuilder ? "builder kept" : "builder deleted"); + +let assignedSlug: string | null = null; +try { + await fs.vms.snapshots.update(snapshotId, { slug }); + assignedSlug = slug; +} catch (error) { + if (!replaceSlug) { + console.warn(`slug ${slug} not assigned (${String(error).slice(0, 160)}); pass --replace-slug to move it. The id is the pointer.`); + } else { + const { snapshots } = await fs.vms.snapshots.list(); + const holder = snapshots.find((candidate) => candidate.slug === slug && candidate.id !== snapshotId); + if (!holder) throw error; + await fs.vms.snapshots.update(holder.id, { slug: "" }); + console.log(`slug ${slug} released from ${holder.id}`); + await fs.vms.snapshots.update(snapshotId, { slug }); + assignedSlug = slug; + } +} const metadata = bakeMetadata(preflight, fileURLToPath(import.meta.url)); -console.log( - JSON.stringify( - { - manifestEntry: manifestEntrySkeleton( - "freestyle", - `freestyle-${slug}`, - snapshotId, - "FREESTYLE_SANDBOX_SNAPSHOT", - metadata, - "Shared devbox exec-replay on the Freestyle public platform (api.freestyle.sh); cmux-tui transport.", - ), - next: `bun scripts/verify-devbox-image.ts freestyle ${snapshotId}`, - }, - null, - 2, +emitBakeResult({ + provider: "freestyle", + imageId: snapshotId, + slug: assignedSlug, + builderSnapshot, + desktop: withDesktop, + manifestEntry: manifestEntrySkeleton( + "freestyle", + `freestyle-${slug}`, + snapshotId, + "FREESTYLE_SANDBOX_SNAPSHOT", + metadata, + withDesktop + ? `Devbox on the Freestyle public platform (api.freestyle.sh) from ${builderSnapshot}: 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; cmux-tui transport.` + : `Devbox on the Freestyle public platform (api.freestyle.sh) from ${builderSnapshot}: 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; cmux-tui transport.`, + withDesktop ? "desktop" : "base", ), -); + next: `bun scripts/verify-devbox-image.ts freestyle ${snapshotId}`, +}); diff --git a/web/scripts/cloud-vm/defaultProviderAudit.mjs b/web/scripts/cloud-vm/defaultProviderAudit.mjs index 57d1707da4ea..2b118d73ed8d 100644 --- a/web/scripts/cloud-vm/defaultProviderAudit.mjs +++ b/web/scripts/cloud-vm/defaultProviderAudit.mjs @@ -53,31 +53,35 @@ export function auditProviderReadiness(provider, env, manifest) { return { provider, envVar: null, image: null, problems }; } + // The checked-in manifest is the only source of truth for images: deployed + // runtimes serve the entry flagged defaultForKind (services/vms/images/ + // resolver.ts). No env var selects an image any more; a lingering selector + // is stale configuration that would mislead whoever reads the deployment. const envVar = entries[0].envVar; - const image = env[envVar]?.trim() || null; - if (!image) { + const kindDefaults = entries.filter((entry) => entry.defaultForKind === true); + for (const entry of kindDefaults.filter((entry) => entry.validationStatus !== "passed")) { problems.push( - `${envVar} is not set; deployed runtimes fail closed on imageless ` + - `creates for provider ${provider}`, + `manifest default ${entry.version} (${entry.kind ?? "base"}) for ${provider} has ` + + `validationStatus ${entry.validationStatus}, not passed`, ); - } else if (image === SENSITIVE_PLACEHOLDER) { + } + // Only a validated default counts as the served image: an unvalidated one + // is already reported above and must not be echoed back as the selection. + const baseDefault = kindDefaults.find( + (entry) => (entry.kind ?? "base") === "base" && entry.validationStatus === "passed", + ); + const image = baseDefault?.imageId ?? null; + if (!baseDefault) { problems.push( - `${envVar} is stored as a Sensitive env var, so its value cannot be audited; ` + - "store it as a plain env var (image ids are configuration, not secrets)", + `the manifest has no validated base default for ${provider} (no entry flagged ` + + "defaultForKind); deployed runtimes fail closed on imageless creates", + ); + } + if (envVar && env[envVar] !== undefined) { + problems.push( + `${envVar} is set but ignored: the manifest is the source of truth for images; ` + + "remove it from the deployment", ); - } else { - const entry = entries.find((candidate) => candidate.imageId === image || candidate.version === image); - if (!entry) { - problems.push( - `${envVar}=${image} is not listed in the image manifest for ${provider}; ` + - "deployed runtimes will reject it with vm_image_config_error", - ); - } else if (entry.validationStatus !== "passed") { - problems.push( - `${envVar} selects manifest entry ${entry.version} whose validationStatus is ` + - `${entry.validationStatus}, not passed`, - ); - } } const credentialKeys = PROVIDER_CREDENTIAL_KEYS[provider]; @@ -106,7 +110,7 @@ export function auditProviderReadiness(provider, env, manifest) { problems.push(`${enabledKey} disables provider ${provider}, so the selected default cannot create VMs`); } - return { provider, envVar, image, problems }; + return { provider, envVar, image, imageSource: "manifest", problems }; } function isFalseFlag(value) { diff --git a/web/scripts/cloud-vm/projects.mjs b/web/scripts/cloud-vm/projects.mjs index 5238ed59bc22..1daa4e5b228c 100755 --- a/web/scripts/cloud-vm/projects.mjs +++ b/web/scripts/cloud-vm/projects.mjs @@ -44,7 +44,6 @@ export const requiredRuntimeEnvKeys = [ // Every Vercel cron (VM alerts included) refuses to run without it. "CRON_SECRET", "FREESTYLE_API_KEY", - "FREESTYLE_SANDBOX_SNAPSHOT", "NEXT_PUBLIC_STACK_PROJECT_ID", "NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY", "PGDATABASE", diff --git a/web/scripts/devbox-image-common.ts b/web/scripts/devbox-image-common.ts index 5569dc0b6a50..7bc367789ba5 100644 --- a/web/scripts/devbox-image-common.ts +++ b/web/scripts/devbox-image-common.ts @@ -1,7 +1,7 @@ /** - * Shared plumbing for the cmux Cloud devbox image bakes - * (build-devbox-freestyle.ts) - * and the post-bake verifier (verify-devbox-image.ts). + * Shared plumbing for the cmux Cloud devbox image bake + * (build-devbox-freestyle.ts), the post-bake verifier + * (verify-devbox-image.ts), and the promote step (promote-devbox-image.ts). * * The image source of truth is web/services/vms/images/devbox/: a plain * Dockerfile plus the files it COPYs. No daemon binary is baked: cmux-tui is @@ -11,7 +11,7 @@ */ import { execSync } from "node:child_process"; import { createHash } from "node:crypto"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -28,9 +28,33 @@ export const DEVBOX_TEMPLATE_FILES = [ "chrome-managed-policy.json", "cmux-bashrc", "cmux-devbox-boot", + "cmux-motd", "seed-history", ] as const; +/** + * The desktop layer (ported from the retired Blaxel cmux-devbox image): an + * openbox/TigerVNC desktop with a tint2 dock, Ghostty, Chrome, Thunar and + * noVNC on 6901. Baked by build-devbox-freestyle.ts (a full VM with + * systemd); the container providers stay shell-only. + */ +export const devboxDesktopDir = path.join(devboxDir, "desktop"); +export const DEVBOX_DESKTOP_FILES = [ + "WALLPAPER.md", + "cmux-desktop-boot", + "cmux-desktop.service", + "ghostty-cmux.desktop", + "google-chrome-cmux.desktop", + "start-vnc.sh", + "thunar-cmux.desktop", + "tint2rc", + "wallpaper.jpg", +] as const; + +/** Ghostty ships no upstream .deb; this community build is pinned by release tag. */ +export const DEVBOX_GHOSTTY_DEB_URL = + "https://github.com/mkasberg/ghostty-ubuntu/releases/download/1.3.1-0-ppa2/ghostty_1.3.1-0.ppa2_amd64_24.04.deb"; + 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" }, @@ -54,6 +78,13 @@ export function readDevboxDockerfile(): string { return readFileSync(devboxDockerfilePath, "utf8"); } +/** 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]; + if (!version) throw new Error("devbox Dockerfile is missing CUA_DRIVER_RS_VERSION"); + return version; +} + export function devboxImageEpoch(dockerfile = readDevboxDockerfile()): string { return /CMUX_IMAGE_EPOCH=([^\s"]+)/.exec(dockerfile)?.[1] ?? "none"; } @@ -62,6 +93,18 @@ export function devboxTemplateFile(name: string): string { return readFileSync(path.join(devboxDir, name), "utf8"); } +export function devboxDesktopFile(name: string): string { + return readFileSync(path.join(devboxDesktopDir, name), "utf8"); +} + +/** Raw bytes of a devbox template file (`desktop/` for the desktop layer). */ +export function devboxFileBytes(name: string): Uint8Array { + const file = name.startsWith("desktop/") + ? path.join(devboxDesktopDir, name.slice("desktop/".length)) + : path.join(devboxDir, name); + return new Uint8Array(readFileSync(file)); +} + export function fileBase64(name: string): string { return readFileSync(path.join(devboxDir, name)).toString("base64"); } @@ -80,12 +123,19 @@ function git(args: string, cwd: string): string { * so there are no base64 embeds to drift-check. Branch bakes are deliberate * with CMUX_BAKE_ALLOW_BRANCH=1. */ -export function bakePreflight(): { sha: string; epoch: string } { +export function bakePreflight(options: { desktop?: boolean } = {}): { sha: string; epoch: string } { for (const name of DEVBOX_TEMPLATE_FILES) { if (!existsSync(path.join(devboxDir, name))) { throw new Error(`bake refused: ${name} is missing from ${devboxDir}`); } } + if (options.desktop) { + for (const name of DEVBOX_DESKTOP_FILES) { + if (!existsSync(path.join(devboxDesktopDir, name))) { + throw new Error(`bake refused: desktop/${name} is missing from ${devboxDesktopDir}`); + } + } + } const allowBranch = process.env.CMUX_BAKE_ALLOW_BRANCH === "1"; execSync("git fetch --quiet origin main", { cwd: repoRoot }); const head = git("rev-parse HEAD", repoRoot); @@ -140,23 +190,49 @@ export function bakeMetadata( }; } +export type DevboxProvider = "freestyle"; +export type DevboxImageKind = "desktop" | "base"; + +/** One `images[]` row of services/vms/images/manifest.json, as the bake scripts emit it. */ +export type DevboxManifestEntry = { + provider: DevboxProvider; + version: string; + imageId: string; + /** Legacy: nothing reads it; kept so old entries parse. */ + envVar: string; + /** Legacy: local dev uses the same `defaultForKind` entry as production. */ + defaultForLocalDev?: boolean; + kind?: DevboxImageKind; + defaultForKind?: boolean; + cmuxdRemoteCommit: string; + /** The cmux commit whose devbox definition produced this image. */ + repoCommit?: string; + builtAt: string; + builderScriptVersion: string; + agentToolResolvedVersions: Record; + validationStatus: "passed" | "failed" | "unknown"; + notes?: string; +}; + export function manifestEntrySkeleton( - provider: "freestyle", + provider: DevboxProvider, version: string, imageId: string, envVar: string, metadata: DevboxBakeMetadata, extraNotes = "", -): Record { + kind?: DevboxImageKind, +): DevboxManifestEntry { return { provider, version, imageId, envVar, - defaultForLocalDev: false, + ...(kind ? { kind } : {}), // The session daemon is cmux-tui, installed at create time from the pinned // artifacts manifest; no cmuxd-remote build is baked. cmuxdRemoteCommit: "none-cmux-tui", + repoCommit: metadata.repoCommit, builtAt: metadata.builtAt, builderScriptVersion: metadata.builderScriptVersion, agentToolResolvedVersions: metadata.agentToolResolvedVersions, @@ -168,3 +244,148 @@ export function manifestEntrySkeleton( ].filter(Boolean).join(" "), }; } + +/** + * The bake's machine-readable result. `--out ` on a bake script writes + * it there so promote-devbox-image.ts never has to scrape build logs. + */ +export type DevboxBakeResult = { + readonly provider: DevboxProvider; + readonly imageId: string; + readonly manifestEntry: DevboxManifestEntry; + readonly next: string; + readonly [extra: string]: unknown; +}; + +export function emitBakeResult(result: DevboxBakeResult): void { + console.log(JSON.stringify(result, null, 2)); + const out = argValue("--out"); + if (out) { + writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`); + console.log(`bake result written to ${out}`); + } + // The last stdout line is the image id alone, so shell callers can + // `tail -n 1` it without parsing JSON. + console.log(`IMAGE_ID ${result.imageId}`); +} + +// --------------------------------------------------------------------------- +// Manifest promotion: the checked-in manifest is the source of truth for the +// image users get, so "promote" is a pure edit of that file that a PR +// carries. Only verified images are promotable, and a provider+kind has +// exactly one default at a time. +// --------------------------------------------------------------------------- + +export const imageManifestPath = path.join(webRoot, "services/vms/images/manifest.json"); + +export type DevboxImageManifest = { + schemaVersion: number; + images: DevboxManifestEntry[]; +}; + +export function readImageManifest(file = imageManifestPath): DevboxImageManifest { + const parsed = JSON.parse(readFileSync(file, "utf8")) as DevboxImageManifest; + if (parsed.schemaVersion !== 1 || !Array.isArray(parsed.images)) { + throw new Error(`${file}: unsupported image manifest shape`); + } + return parsed; +} + +export function writeImageManifest(manifest: DevboxImageManifest, file = imageManifestPath): void { + writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`); +} + +export type PromoteImageOptions = { + /** Kinds this image serves. Each gets its own entry flagged `defaultForKind`. */ + readonly kinds: readonly DevboxImageKind[]; + /** Human-readable validation summary appended to `notes`. */ + readonly validationNotes?: string; +}; + +/** + * Appends a verified image to the manifest as the default for every kind in + * `kinds`, demoting the provider's previous defaults for those kinds. 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. + */ +export function promoteImageManifestEntry( + manifest: DevboxImageManifest, + entry: DevboxManifestEntry, + options: PromoteImageOptions, +): DevboxImageManifest { + if (entry.validationStatus !== "passed") { + throw new Error( + `refusing to promote ${entry.provider} ${entry.imageId}: validationStatus is ` + + `${entry.validationStatus}, not passed (run verify-devbox-image.ts first)`, + ); + } + if (options.kinds.length === 0) { + throw new Error(`refusing to promote ${entry.imageId}: no kinds given`); + } + const kinds = [...new Set(options.kinds)]; + for (const kind of kinds) { + const clash = manifest.images.find((candidate) => + candidate.provider === entry.provider && + candidate.imageId === entry.imageId && + (candidate.kind ?? "base") === kind + ); + if (clash) { + throw new Error( + `refusing to promote ${entry.provider} ${entry.imageId}: already listed as ${clash.version} (${kind})`, + ); + } + } + const demoted = manifest.images.map((candidate) => { + if (candidate.provider !== entry.provider) return candidate; + const next: DevboxManifestEntry = { ...candidate }; + if (next.defaultForKind && kinds.includes(next.kind ?? "base")) next.defaultForKind = false; + return next; + }); + const notes = [entry.notes, options.validationNotes].filter(Boolean).join(" "); + const promoted = kinds.map((kind): DevboxManifestEntry => ({ + ...entry, + version: kinds.length > 1 && kind !== kinds[0] ? `${entry.version}-${kind}` : entry.version, + kind, + defaultForKind: true, + ...(notes ? { notes } : {}), + })); + return { schemaVersion: manifest.schemaVersion, images: [...demoted, ...promoted] }; +} + +/** + * Invariants the checked-in manifest must hold; tests/vm-image-manifest.test.ts + * runs this against the real file, promote-devbox-image.ts against its output. + */ +export function imageManifestProblems(manifest: DevboxImageManifest): string[] { + const problems: string[] = []; + const defaults = new Map(); + for (const entry of manifest.images) { + for (const field of ["provider", "version", "imageId", "envVar", "builtAt", "validationStatus"] as const) { + if (!entry[field]) problems.push(`${entry.version ?? entry.imageId ?? "?"}: missing ${field}`); + } + if (!["passed", "failed", "unknown"].includes(entry.validationStatus)) { + problems.push(`${entry.version}: validationStatus ${String(entry.validationStatus)} is not passed|failed|unknown`); + } + if (entry.kind !== undefined && entry.kind !== "desktop" && entry.kind !== "base") { + problems.push(`${entry.version}: kind ${String(entry.kind)} is not desktop|base`); + } + if (entry.defaultForKind) { + if (entry.validationStatus !== "passed") { + problems.push(`${entry.version}: defaultForKind but validationStatus is ${entry.validationStatus}`); + } + const key = `${entry.provider}/${entry.kind ?? "base"}`; + defaults.set(key, [...(defaults.get(key) ?? []), entry]); + } + } + const versions = manifest.images.map((entry) => `${entry.provider}/${entry.version}`); + for (const dup of versions.filter((v, i) => versions.indexOf(v) !== i)) { + problems.push(`${dup}: version listed more than once`); + } + for (const [key, entries] of defaults) { + if (entries.length > 1) { + problems.push(`${key}: ${entries.length} entries flagged defaultForKind (${entries.map((e) => e.version).join(", ")})`); + } + } + return problems; +} diff --git a/web/scripts/load-dev-env.sh b/web/scripts/load-dev-env.sh index ee759bd9c100..d691c148618d 100755 --- a/web/scripts/load-dev-env.sh +++ b/web/scripts/load-dev-env.sh @@ -20,8 +20,6 @@ cmux_existing_db_password_set="${CMUX_DB_PASSWORD+x}" cmux_existing_db_password="${CMUX_DB_PASSWORD-}" cmux_existing_db_name_set="${CMUX_DB_NAME+x}" cmux_existing_db_name="${CMUX_DB_NAME-}" -cmux_existing_freestyle_snapshot_set="${FREESTYLE_SANDBOX_SNAPSHOT+x}" -cmux_existing_freestyle_snapshot="${FREESTYLE_SANDBOX_SNAPSHOT-}" cmux_extra_secret_file="${CMUXTERM_EXTRA_ENV_FILE:-${CMUX_WEB_EXTRA_ENV_FILE:-}}" if [[ -z "$cmux_extra_secret_file" && -f "$HOME/.secrets/cmux.env" ]]; then @@ -82,7 +80,6 @@ if [[ -n "$cmux_existing_db_port_set" ]]; then export CMUX_DB_PORT="$cmux_existi if [[ -n "$cmux_existing_db_user_set" ]]; then export CMUX_DB_USER="$cmux_existing_db_user"; fi if [[ -n "$cmux_existing_db_password_set" ]]; then export CMUX_DB_PASSWORD="$cmux_existing_db_password"; fi if [[ -n "$cmux_existing_db_name_set" ]]; then export CMUX_DB_NAME="$cmux_existing_db_name"; fi -if [[ -n "$cmux_existing_freestyle_snapshot_set" ]]; then export FREESTYLE_SANDBOX_SNAPSHOT="$cmux_existing_freestyle_snapshot"; fi cmux_port="${CMUX_PORT:-${PORT:-3777}}" if [[ ! "$cmux_port" =~ ^[0-9]+$ ]]; then diff --git a/web/scripts/promote-devbox-image.ts b/web/scripts/promote-devbox-image.ts new file mode 100644 index 000000000000..a737878441b5 --- /dev/null +++ b/web/scripts/promote-devbox-image.ts @@ -0,0 +1,210 @@ +#!/usr/bin/env bun +/** + * Bake, verify, and record a cmux Cloud devbox image in one command. The + * checked-in manifest (web/services/vms/images/manifest.json) is the source + * of truth for the image users get, so the output of this script is a + * manifest diff to commit; the LAST stdout line is `IMAGE_ID `. + * + * Usage: + * bun scripts/promote-devbox-image.ts freestyle [--slug cmux-devbox-] + * [--kinds desktop,base] [--pointer-slug cmux-devbox] + * [--image ] [--skip-verify] [--out ] + * [--replace-slug] [--no-desktop] [--dry-run] + * + * --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. + * --kinds machine kinds the image serves; each gets a manifest entry + * flagged defaultForKind (default: desktop,base for a desktop + * bake, base for --no-desktop). A desktop image is a superset + * of a base one, so one snapshot can serve both. + * --pointer-slug After promotion, move this account-local + * snapshot slug onto the new id (default cmux-devbox; "none" + * disables). A human/dashboard convenience: production boots + * from the immutable id in the manifest, never from the slug. + * --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. + * + * Steps: bakePreflight (stale checkout guard) -> bake script (--out) -> + * verify-devbox-image.ts (boots one VM, deletes it) -> manifest write -> + * slug pointer. A failed verify writes nothing and exits 1. + * + * Run it from web/ with FREESTYLE_API_KEY in the environment of the Freestyle + * account the deployment uses, then commit the manifest diff in a PR. + */ +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { + argValue, + bakeMetadata, + bakePreflight, + defaultBakeTag, + hasFlag, + imageManifestPath, + imageManifestProblems, + manifestEntrySkeleton, + promoteImageManifestEntry, + readImageManifest, + webRoot, + writeImageManifest, + type DevboxBakeResult, + type DevboxImageKind, + type DevboxManifestEntry, + type DevboxProvider, +} from "./devbox-image-common"; + +const provider = process.argv[2] as DevboxProvider | undefined; +if (provider !== "freestyle") { + throw new Error("usage: bun scripts/promote-devbox-image.ts freestyle [options]"); +} + +const withDesktop = !hasFlag("--no-desktop"); +const requestedKinds = (argValue("--kinds") ?? (withDesktop ? "desktop,base" : "base")) + .split(",") + .map((kind) => kind.trim()) + .filter(Boolean); +for (const kind of requestedKinds) { + if (kind !== "desktop" && kind !== "base") throw new Error(`--kinds: unknown kind ${kind}`); +} +const kinds = requestedKinds as DevboxImageKind[]; +if (kinds.includes("desktop") && !withDesktop) { + throw new Error("--kinds desktop needs a desktop bake (drop --no-desktop)"); +} +const pointerSlug = argValue("--pointer-slug") ?? "cmux-devbox"; +const skipVerify = hasFlag("--skip-verify"); +const dryRun = hasFlag("--dry-run"); +const existingImage = argValue("--image"); +const tag = defaultBakeTag(); +const slug = argValue("--slug") ?? `cmux-${tag}`; + +const scriptsDir = path.dirname(fileURLToPath(import.meta.url)); +const workDir = mkdtempSync(path.join(tmpdir(), "cmux-devbox-promote-")); + +function run(label: string, args: string[]): number { + console.log(`\n===== ${label}: bun ${args.join(" ")} =====`); + const result = spawnSync("bun", args, { cwd: webRoot, stdio: "inherit", env: process.env }); + if (result.error) throw result.error; + return result.status ?? 1; +} + +// 1. Bake (or adopt an existing image / a previous bake's result file). +let entry: DevboxManifestEntry; +let imageId: string; +const bakeResultPath = argValue("--bake-result"); +if (bakeResultPath) { + const bake = JSON.parse(readFileSync(bakeResultPath, "utf8")) as DevboxBakeResult; + if (bake.provider !== provider) { + throw new Error(`--bake-result is a ${bake.provider} bake, not ${provider}`); + } + entry = bake.manifestEntry; + imageId = bake.imageId; + 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`)); + imageId = existingImage; + entry = manifestEntrySkeleton( + provider, + `${provider}-${argValue("--slug") ?? existingImage}`, + existingImage, + "FREESTYLE_SANDBOX_SNAPSHOT", + metadata, + `Promoted from an existing ${provider} image by promote-devbox-image.ts --image.`, + withDesktop ? "desktop" : "base", + ); +} else { + const bakeOut = path.join(workDir, "bake.json"); + const bakeArgs = [ + "scripts/build-devbox-freestyle.ts", + slug, + "--out", + bakeOut, + ...(hasFlag("--replace-slug") ? ["--replace-slug"] : []), + ...(withDesktop ? [] : ["--no-desktop"]), + ]; + const bakeStatus = run("bake", bakeArgs); + if (bakeStatus !== 0) { + console.error(`bake failed (exit ${bakeStatus}); nothing promoted`); + process.exit(bakeStatus); + } + const bake = JSON.parse(readFileSync(bakeOut, "utf8")) as DevboxBakeResult; + entry = bake.manifestEntry; + imageId = bake.imageId; +} + +// 2. Verify: the only path to validationStatus "passed". +let validationNotes: string; +if (skipVerify) { + entry = { ...entry, validationStatus: "unknown" }; + validationNotes = "NOT VERIFIED (promote --skip-verify); verify before flagging as a default."; +} else { + // The bake result or --no-desktop says which layer the image should carry; + // verify reads the baked stamp and fails on a mismatch, so a base image can + // never land as the desktop default. + const expectedKind: DevboxImageKind = entry.kind ?? (withDesktop ? "desktop" : "base"); + const verifyStatus = run("verify", ["scripts/verify-devbox-image.ts", provider, imageId, "--expect-kind", expectedKind]); + if (verifyStatus !== 0) { + console.error(`verify failed (exit ${verifyStatus}) for ${provider} ${imageId}; nothing promoted`); + process.exit(verifyStatus); + } + entry = { ...entry, validationStatus: "passed" }; + validationNotes = + `Validated ${new Date().toISOString().slice(0, 10)} with verify-devbox-image.ts by promote-devbox-image.ts` + + (withDesktop ? " (toolchain, agent pins, daemon contract, desktop on 5901/6901)." : " (toolchain, agent pins, daemon contract)."); +} + +// 3. Manifest: append and flip defaults (pure edit), then re-check invariants. +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, validationNotes }); +const problems = imageManifestProblems(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)`); +} + +// 4. Pointer slug: a readable "current" handle on the platform. +let pointer: string | null = null; +if (!dryRun && !skipVerify && pointerSlug !== "none") { + const { Freestyle } = await import("freestyle"); + const fs = new Freestyle({ baseUrl: process.env.FREESTYLE_API_URL?.trim() || undefined }); + try { + await fs.vms.snapshots.update(imageId, { slug: pointerSlug }); + } catch { + const { snapshots } = await fs.vms.snapshots.list(); + const holder = snapshots.find((candidate) => candidate.slug === pointerSlug && candidate.id !== imageId); + if (holder) { + await fs.vms.snapshots.update(holder.id, { slug: "" }); + console.log(`pointer slug ${pointerSlug} released from ${holder.id}`); + } + await fs.vms.snapshots.update(imageId, { slug: pointerSlug }); + } + pointer = pointerSlug; + console.log(`pointer slug ${pointerSlug} -> ${imageId}`); +} + +const result = { + provider, + imageId, + kinds, + versions: added.map((row) => row.version), + validationStatus: entry.validationStatus, + pointerSlug: pointer, + manifest: dryRun ? null : imageManifestPath, +}; +console.log(JSON.stringify(result, null, 2)); +const out = argValue("--out"); +if (out) writeFileSync(out, `${JSON.stringify(result, null, 2)}\n`); +console.log(`IMAGE_ID ${imageId}`); diff --git a/web/scripts/verify-devbox-image.ts b/web/scripts/verify-devbox-image.ts index c6176b96cb49..6aeda681c9d7 100644 --- a/web/scripts/verify-devbox-image.ts +++ b/web/scripts/verify-devbox-image.ts @@ -22,14 +22,14 @@ import { Freestyle } from "freestyle"; import path from "node:path"; import { CMUX_TUI_SESSION, - cmuxTuiDaemonCommand, cmuxTuiInstallCommand, resolveCmuxTuiSource, } from "../services/vms/drivers/cmuxTuiDaemon"; -import { devboxAgentPins, devboxDir, sha256File } from "./devbox-image-common"; +import { devboxAgentPins, devboxDesktopDir, devboxDir, sha256File } from "./devbox-image-common"; const pins = devboxAgentPins(); const shaOf = (name: string): string => sha256File(path.join(devboxDir, name)); +const desktopShaOf = (name: string): string => sha256File(path.join(devboxDesktopDir, name)); // Every file the image bakes from this checkout must ship byte-identical. const FILE_PIN_CHECKS = [ @@ -46,9 +46,8 @@ const CHECKS: readonly string[] = [ .map((pin) => `echo "$ls" | grep -F ' ${pin.spec}'`) .join(" && ")} && echo agent-pins-ok`, ...pins.map((pin) => `${pin.binary} --version`), - // Toolchain: mise shims first on PATH for exec shells too. - "node --version; npm --version; python --version; python3 --version; bun --version; uv --version", - "test \"$(command -v node)\" = /opt/mise/shims/node && mise --version && echo mise-shims-ok", + // Toolchain present (where it comes from is provider-specific, below). + "node --version && npm --version && python --version && python3 --version && bun --version && uv --version && echo toolchain-ok", "git --version; rg --version | head -1", "jq --version; fd --version; fzf --version; gh --version | head -1; sqlite3 --version; tmux -V; rsync --version | head -1; file --version | head -1; tree --version; vim --version | head -1", // Chrome + managed policy + browser/computer-use drivers. @@ -61,7 +60,7 @@ const CHECKS: readonly string[] = [ ...FILE_PIN_CHECKS, // Devshell: ble.sh installed, bashrc chained, tmux pinned to bash, seed // history lands on first interactive shell. - "test -f /usr/local/share/blesh/ble.sh && grep -q '/etc/cmux/bashrc' /etc/bash.bashrc && grep -q '/etc/cmux/bashrc' /etc/skel/.bashrc && echo bashrc-chain-ok", + "test -f /usr/local/share/blesh/ble.sh && grep -q '/etc/cmux/bashrc' /etc/skel/.bashrc && echo bashrc-chain-ok", "grep default-shell /etc/tmux.conf", "bash -ic 'head -2 ~/.bash_history'", // Ghost-text smoke under a real PTY: type "cl" and expect ble.sh to render @@ -89,6 +88,81 @@ const DAEMON_CHECKS: readonly string[] = [ "test \"$(readlink /usr/local/bin/cmux-tui)\" = /root/.cmux/bin/cmux-tui && echo cmux-tui-symlink-ok", ]; +// The desktop layer (Freestyle bakes; /etc/cmux/image-stamp says "desktop"): +// the cmux-desktop systemd unit runs start-vnc.sh as cua, RFB 5901 (hex 170D) +// is loopback-only, noVNC answers on 6901 (hex 1AF5), the dock and window +// manager are up, Ghostty and Chrome are installed with first run +// pre-accepted, and every desktop file ships byte-identical. +// Hashed lazily: a base-only verification must not read the desktop assets. +const desktopFilePinChecks = (): string[] => [ + ["start-vnc.sh", "/usr/local/bin/start-vnc.sh"], + ["cmux-desktop-boot", "/usr/local/bin/cmux-desktop-boot"], + ["cmux-desktop.service", "/etc/systemd/system/cmux-desktop.service"], + ["tint2rc", "/etc/cmux/tint2rc"], + ["wallpaper.jpg", "/usr/share/backgrounds/cmux/wallpaper.jpg"], + ["google-chrome-cmux.desktop", "/etc/cmux/apps/google-chrome-cmux.desktop"], + ["thunar-cmux.desktop", "/etc/cmux/apps/thunar-cmux.desktop"], + ["ghostty-cmux.desktop", "/etc/cmux/apps/ghostty-cmux.desktop"], +].map(([source, target]) => `echo '${desktopShaOf(source)} ${target}' | sha256sum -c -`); + +const desktopChecks = (): readonly string[] => [ + "systemctl is-active cmux-desktop >/dev/null && echo desktop-unit-active", + "awk '$2 ~ /:170D$/ && $4 == \"0A\" { found=1 } END { exit !found }' /proc/net/tcp /proc/net/tcp6 && echo vnc-5901-listening", + // 5901 must be loopback-only: every listener on it is bound to 127.0.0.1 (0100007F) or ::1. + "awk '$2 ~ /:170D$/ && $4 == \"0A\" && $2 !~ /^0100007F:/ && $2 !~ /^00000000000000000000000001000000:/ { bad=1 } END { exit bad }' /proc/net/tcp /proc/net/tcp6 && echo vnc-5901-loopback-only", + "awk '$2 ~ /:1AF5$/ && $4 == \"0A\" { found=1 } END { exit !found }' /proc/net/tcp /proc/net/tcp6 && echo novnc-6901-listening", + "curl -fsS http://127.0.0.1:6901/ | grep -qi novnc && echo novnc-6901-serves-client", + // start-vnc.sh runs whichever of Xvnc/Xtigervnc is on PATH; the process + // name follows the invoked path (Ubuntu's Xvnc is a symlink to Xtigervnc). + "pgrep -u ubuntu -x 'Xvnc|Xtigervnc' >/dev/null && pgrep -u ubuntu -x openbox >/dev/null && pgrep -u ubuntu -x tint2 >/dev/null && echo desktop-session-ok", + "runuser -u ubuntu -- env DISPLAY=:1 xdpyinfo | grep dimensions", + "ghostty +version | head -1", + "test -f '/home/ubuntu/.config/google-chrome/First Run' && echo chrome-first-run-ok", + "test -s /etc/cmux/icons/google-chrome.png && test -s /etc/cmux/icons/thunar.png && test -s /etc/cmux/icons/ghostty.png && echo dock-icons-ok", + ...desktopFilePinChecks(), +]; + +// 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, +// Docker) with the pinned agents installed on top, and the pins must win in +// every shell family: a clean login shell (no PATH help from this verifier) +// and a daemon pane (non-login, the unit's PATH). +const FREESTYLE_BASE_CHECKS: readonly string[] = [ + "[ \"$(id -u ubuntu)\" = 1000 ] && sudo -n -u ubuntu sudo -n true && echo ubuntu-user-sudo-ok", + "sudo -n -u ubuntu bash -ic 'head -1 ~/.bash_history' | grep -q claude && echo ubuntu-user-shell-ok", + "test ! -e /opt/mise && test ! -e /usr/local/bin/mise && readlink /usr/local/bin/node | grep -q /usr/local/nvm/ && echo base-toolchain-in-use", + "for b in node claude codex opencode pi agent-browser bun; do test -L /usr/local/bin/$b || exit 1; done && echo agent-symlinks-ok", + ...pins.map((pin) => `env -i HOME=/home/ubuntu TERM=xterm sudo -n -u ubuntu bash -lc '${pin.binary} --version' | grep -F '${pin.version}' >/dev/null && echo ${pin.binary}-login-pin-ok`), + // Non-login probe, as the work user: probing as root with HOME=/home/ubuntu + // would itself leave root-owned state dirs behind. + ...pins.map((pin) => `sudo -n -u ubuntu env -i HOME=/home/ubuntu USER=ubuntu TERM=xterm PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin ${pin.binary} --version | grep -F '${pin.version}' >/dev/null && echo ${pin.binary}-nonlogin-pin-ok`), + "systemctl show cmux-tui-daemon -p Environment | grep -q 'PATH=/usr/local/sbin:/usr/local/bin:' && echo daemon-env-path-ok", + "docker --version && sudo -n -u ubuntu docker ps >/dev/null && echo docker-ok", + // Home hygiene: nothing root-owned in the work user's home, ble.sh's + // fallback state dir writable, the legal-notice marker present, and two + // real interactive logins as the work user print nothing from ble.sh or + // the shell (a `bash -c` probe would not load ble.sh at all). + "[ \"$(find /home/ubuntu -not -user ubuntu | wc -l)\" = 0 ] && echo home-owned-by-ubuntu", + "[ \"$(stat -c %a /usr/local/share/blesh/state.d)\" = 1777 ] && [ \"$(stat -c %a /usr/local/share/blesh/cache.d)\" = 1777 ] && echo blesh-dirs-ok", + "test -f /home/ubuntu/.cache/motd.legal-displayed && test -f /root/.cache/motd.legal-displayed && test -f /etc/skel/.cache/motd.legal-displayed && echo legal-notice-silenced", + ...[1, 2].map((run) => + `sudo -n -u ubuntu env -i HOME=/home/ubuntu USER=ubuntu TERM=xterm-256color PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash -c 'tmux -L vprobe${run} new-session -d -s login -x 120 -y 30 && sleep 3 && pane="$(tmux -L vprobe${run} capture-pane -pt login)"; tmux -L vprobe${run} kill-server 2>/dev/null; printf "%s\\n" "$pane" | grep -iE "ble\\.sh|bleopt|ble-face|denied|not found|WARRANTY${run > 1 ? "|updating tput" : ""}" && { printf "%s\\n" "$pane"; exit 1; }; printf "%s\\n" "$pane" | grep -q "λ" && echo ubuntu-login-silent-${run}'`, + ), + // The devshell chain lives in the per-user rc files (after Ubuntu's own + // PS1), never in /etc/bash.bashrc, so it loads once and the cmux prompt wins. + "grep -q '/etc/cmux/bashrc' /home/ubuntu/.bashrc && grep -q '/etc/cmux/bashrc' /etc/skel/.bashrc && ! grep -q '/etc/cmux/bashrc' /etc/bash.bashrc && echo devshell-sourced-once", + // The login banner is cmux's and offline. + "run-parts /etc/update-motd.d | grep -q 'persistent cloud VM' && ! run-parts /etc/update-motd.d | grep -qi 'ubuntu.com' && test ! -s /etc/motd && echo motd-ok", + // ble.sh tput-cache seeds are readable by the work user and land in its + // XDG cache verbatim on first shell, so no login prints the tput notice. + "[ \"$(find /etc/cmux/blesh-cache-seed -not -perm -o+r | wc -l)\" = 0 ] && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.xterm-ghostty && echo blesh-seeds-readable", + "sudo -n -u ubuntu env -i HOME=/home/ubuntu USER=ubuntu TERM=xterm-256color PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin bash -c 'rm -rf ~/.cache/blesh; tmux -L seed new-session -d -s s -x 100 -y 24 \"env TERM=xterm-256color bash -i\" && sleep 3; tmux -L seed kill-server 2>/dev/null; cmp ~/.cache/blesh/*/term.xterm-256color /etc/cmux/blesh-cache-seed/blesh/*/term.xterm-256color' && echo blesh-cache-seeded", + `echo '${shaOf("cmux-motd")} /etc/update-motd.d/00-cmux' | sha256sum -c -`, + "cat /etc/cmux/tool-versions", + "cat /etc/cmux/image-stamp", +]; + type Exec = (cmd: string, timeoutMs?: number) => Promise<{ exitCode: number; output: string }>; async function runChecks(label: string, checks: readonly string[], exec: Exec): Promise { @@ -135,7 +209,15 @@ async function bootstrapDaemon( const provider = process.argv[2] ?? ""; const image = process.argv[3] ?? ""; if (!image) { - throw new Error("usage: bun scripts/verify-devbox-image.ts freestyle "); + throw new Error("usage: bun scripts/verify-devbox-image.ts freestyle [--expect-kind desktop|base]"); +} +// The caller's belief about the image (promote-devbox-image.ts derives it from +// --no-desktop). The stamp baked into the image is the truth; a mismatch fails +// the verification so a base image is never promoted as the desktop default. +const expectKindIndex = process.argv.indexOf("--expect-kind"); +const expectKind = expectKindIndex === -1 ? undefined : process.argv[expectKindIndex + 1]; +if (expectKind !== undefined && expectKind !== "desktop" && expectKind !== "base") { + throw new Error(`--expect-kind: expected desktop or base, got ${expectKind ?? "(nothing)"}`); } let pass = false; @@ -174,11 +256,24 @@ if (provider === "freestyle") { }; // The baked cmux-tui-daemon systemd unit supervises the daemon. await bootstrapDaemon("freestyle", exec, async () => {}); + // The image stamp says which layers were baked; a desktop image must + // pass the desktop contract, a base image must not carry a desktop. + const stamp = await exec("cat /etc/cmux/image-stamp 2>/dev/null || true", 30_000); + const desktop = /\bdesktop\b/.test(stamp.output); + console.log(`image stamp: ${stamp.output.trim() || "(none)"} -> desktop checks ${desktop ? "on" : "off"}`); + const stampKind = desktop ? "desktop" : "base"; + if (expectKind !== undefined && expectKind !== stampKind) { + throw new Error(`image stamp says ${stampKind} but --expect-kind ${expectKind} was requested`); + } pass = await runChecks("freestyle", [ ...CHECKS, ...DAEMON_CHECKS, // The baked systemd unit is the daemon supervisor across reboots. "systemctl is-active cmux-tui-daemon >/dev/null && echo systemd-supervisor-active", + ...FREESTYLE_BASE_CHECKS, + ...(desktop + ? desktopChecks() + : ["test ! -e /usr/local/bin/start-vnc.sh && echo base-image-has-no-desktop"]), ], exec); } finally { await vm.delete(); diff --git a/web/services/vms/README.md b/web/services/vms/README.md index bd13d713ffb8..0a6d03164f0f 100644 --- a/web/services/vms/README.md +++ b/web/services/vms/README.md @@ -70,61 +70,57 @@ Active VM limits are enforced inside the same Postgres transaction that inserts ## Image manifest and rollback -Known-good provider images are recorded in `services/vms/images/manifest.json`. Each entry records -the provider, provider image id, cmux image version, build metadata, and validation status. +Known-good images are recorded in `services/vms/images/manifest.json`. Each entry records the +provider, image id, cmux image version, build metadata (`repoCommit`, agent pins), and +validation status. **The manifest is the only source of truth for the image users get; no env +var selects or overrides it.** -Default image policy: +Image policy: -- Production and staging select images with `FREESTYLE_SANDBOX_SNAPSHOT`. A - provider that ships a desktop image also gets a `_DESKTOP_IMAGE` selector; - Freestyle does not today. - Clients request a machine **kind** (`kind: "desktop" | "base"` on `POST /api/vm`, `POST /api/vm/base/open`, and `POST /api/vm/base/reset`) rather than pinning an image id. With - no `image`, the resolver picks the kind's env var, then the manifest entry flagged - `kind` + `defaultForKind` (also in deployed runtimes), and only then fails. `image` still wins - when present, and a body with neither keeps the legacy single-image behavior. Responses and - `GET /api/vm` entries echo `kind`; `GET /api/vm` `limits.imageKinds` lists the kinds the + no `image`, the resolver serves the manifest entry flagged `kind` + `defaultForKind` (a body with + neither `image` nor `kind` gets the `base` default) and otherwise fails closed with + `vm_image_config_error`. `image` still wins when present, but a client-requested `image` must be + in the manifest (or `CMUX_VM_ALLOW_UNMANIFESTED_IMAGES=1`, which local dev implies). Responses + and `GET /api/vm` entries echo `kind`; `GET /api/vm` `limits.imageKinds` lists the kinds the default provider can serve and the image each resolves to. -- An image named by a provider env var is operator configuration and is accepted even when the - manifest does not list it (logged once, `imageVersion: null`). Only a client-requested `image` - must be in the manifest (or `CMUX_VM_ALLOW_UNMANIFESTED_IMAGES=1`). `vm_image_config_error` - responses carry client-safe `details.imageRequested`, `details.kind`, `details.source` - (`request` | `env` | `default`), and `details.allowedKinds`; the provider, env var name, manifest +- `vm_image_config_error` responses carry client-safe `details.imageRequested`, `details.kind`, + `details.source` (`request` | `default`), and `details.allowedKinds`; the provider, manifest image ids, and reason go to the server log (`[vm-image-config-error]`) because API error payloads must not leak provider implementation details (see `expectNoCloudVmImplementationLeaks` in `tests/vm-route-auth.test.ts`). -- Local development uses the manifest entry marked `defaultForLocalDev` when the provider env var - is unset. -- The current intended default provider is Freestyle on the public platform - (`api.freestyle.sh`). Set `CMUX_VM_DEFAULT_PROVIDER=freestyle` (the local loader supplies - this when unset); Freestyle is the only provider, so the override can only ever name it, rather than - silent fallbacks. -- The validated Freestyle devbox entry is `freestyle-cmux-devbox-20260902c`, image - `sh-940ec3bc46224c019e5e8d9a97053293`. It was baked and verified on the public platform - (`api.freestyle.sh`) from main `2526fbf0f2`, including the explicit Codex HTTP-only setting. - The retired beta entry remains only as a historical record and must not be selected. The - previous public entries `freestyle-cmux-devbox-20260902a` and `freestyle-cmux-devbox-20260902b` - remain in the manifest for rollback. When the shared Dockerfile epoch or tool pins change, - run `scripts/build-devbox-freestyle.ts`, verify the new snapshot, and append its id before - changing `FREESTYLE_SANDBOX_SNAPSHOT`. +- 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 base default is `freestyle-cmux-devbox-20260902c`, image + `sh-940ec3bc46224c019e5e8d9a97053293`, baked and verified on cmux's Freestyle account from main + `2526fbf0f2`. The retired beta entry stays listed for the record and is never a default; earlier + public entries stay for rollback. +- Snapshots are account-scoped: a manifest id is only bootable by the Freestyle account whose + `FREESTYLE_API_KEY` the deployment uses. `freestyle-cmux-devbox-20260902h` (the desktop devbox, + `ubuntu` work user, cmux login banner) is validated but listed as a non-default reference bake + for that reason; re-promote it under cmux's key to make it the default. +- Promotion is `bun run devbox:promote -- freestyle` (bake → verify → manifest write), then a PR + with the manifest diff; merging promotes. See + `services/vms/images/devbox/README.md`. `tests/vm-image-manifest.test.ts` holds the invariants: + one `defaultForKind` per provider and kind, unique versions, every default + `validationStatus: "passed"`. - Baked agent tools are installed at image-build time. They are not auto-updated on VM startup, so - startup latency stays bounded and the active image manifest remains the source of truth. -- To update tool versions, rebuild the provider images and record the new template/snapshot IDs in - the manifest. `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`. + 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`. -Vercel production, staging, and preview deployments fail closed for VM create if the selected image -env var is missing or is not listed in the manifest. Local development can use the manifest default -without setting provider image env vars. Set `CMUX_VM_ALLOW_UNMANIFESTED_IMAGES=1` only for local -image experiments. +A leftover `FREESTYLE_SANDBOX_SNAPSHOT` in a deployment is ignored; the env audit reports it as +stale configuration to remove. -Rollback is an env-only operation: +Rollback is a manifest change: -1. Choose a previous manifest entry with `validationStatus: "passed"`. -2. Set `FREESTYLE_SANDBOX_SNAPSHOT` back to that entry's `imageId`. -3. Redeploy staging, smoke test, then repeat for production. -4. Keep old snapshots until all VMs using them are gone. +1. Revert the promotion PR (or flip `defaultForKind` back to a previous entry with + `validationStatus: "passed"`; entries are never removed). +2. Deploy staging, smoke test, then production. +3. Keep old snapshots until all VMs using them are gone. ## Baked tools and VM-local cmux CLI @@ -220,7 +216,6 @@ Set these Vercel environment variables per production/staging environment: machine actually holds. - `CMUX_VM_ALLOWED_ORIGINS`, optional comma-separated extra origins allowed for cookie mutations. - `FREESTYLE_API_KEY`, Freestyle provider key. -- `FREESTYLE_SANDBOX_SNAPSHOT`, Freestyle snapshot id. - `CMUX_VM_DEFAULT_PROVIDER`, only `freestyle` (and its default). - `CMUX_VM_DEFAULT_PLAN`, optional fallback for accounts without plan metadata. It defaults to `free`; paid values are ignored unless `CMUX_VM_ALLOW_FREE_PROVISIONING=1`, so deployment configuration @@ -351,7 +346,7 @@ remote daemon as the machine's only session daemon**, downloading the pinned sta VM. The daemon runs as root with `HOME=/root`; the build and its digest come from the artifacts manifest published by `.github/workflows/cmux-tui-artifacts.yml`, nothing is pinned by hand. Config: `FREESTYLE_API_KEY` (or `FREESTYLE_STACK_ACCESS_TOKEN` + -`FREESTYLE_TEAM_ID`), `FREESTYLE_SANDBOX_SNAPSHOT`; optionally `FREESTYLE_API_URL` to point +`FREESTYLE_TEAM_ID`); optionally `FREESTYLE_API_URL` to point at a non-default edge and `CMUX_VM_CMUX_TUI_MANIFEST_URL` to pin a deployment to one commit's `https://files.cmux.com/cmux-tui//manifest.json` instead of the rolling `latest`. @@ -402,7 +397,7 @@ recreation. Operational note: before rollout, verify the deployed `CMUX_VM_DEFAULT_PROVIDER`, `CMUX_VM_FREESTYLE_ENABLED`, `FREESTYLE_API_KEY`, -and `FREESTYLE_SANDBOX_SNAPSHOT` env values with +env values with `bun run cloud-vm:env:audit -- --strict`, then confirm attach and daemon health with `bun run cloud-vm:stress -- --provider default`. diff --git a/web/services/vms/images/devbox/Dockerfile b/web/services/vms/images/devbox/Dockerfile index 232402454489..b4d3988f7b55 100644 --- a/web/services/vms/images/devbox/Dockerfile +++ b/web/services/vms/images/devbox/Dockerfile @@ -190,6 +190,7 @@ RUN mkdir -p /etc/cmux/blesh-cache-seed \ script -qec 'bash -i' /dev/null >/dev/null 2>&1 || true; \ done \ && rm -rf /tmp/blesh-seed-home \ + && chmod -R a+rX /etc/cmux/blesh-cache-seed \ && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.xterm-256color \ && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.screen-256color \ && test -s /etc/cmux/blesh-cache-seed/blesh/*/term.tmux-256color \ @@ -243,3 +244,14 @@ RUN bash -n /etc/cmux/agent-config.sh \ COPY cmux-devbox-boot /usr/local/bin/cmux-devbox-boot RUN chmod 0755 /usr/local/bin/cmux-devbox-boot \ && sh -n /usr/local/bin/cmux-devbox-boot + +# Login banner (pam_motd on providers that run sshd; inert elsewhere). It +# reads the versions the bake pinned from /etc/cmux/tool-versions so login +# never runs a tool to ask. +COPY cmux-motd /etc/update-motd.d/00-cmux +RUN chmod 0755 /etc/update-motd.d/00-cmux \ + && sh -n /etc/update-motd.d/00-cmux \ + && mkdir -p /etc/cmux \ + && echo "claude ${CMUX_IMAGE_CLAUDE_CODE_VERSION} · codex ${CMUX_IMAGE_CODEX_VERSION} · opencode ${CMUX_IMAGE_OPENCODE_VERSION} · pi ${CMUX_IMAGE_PI_VERSION}" > /etc/cmux/tool-versions \ + && echo "node $(node --version) · python $(python3 --version | awk '{print $2}') · bun $(bun --version) · uv $(uv --version | awk '{print $2}') · gh $(gh --version | head -1 | awk '{print $3}')" >> /etc/cmux/tool-versions \ + && cat /etc/cmux/tool-versions diff --git a/web/services/vms/images/devbox/README.md b/web/services/vms/images/devbox/README.md index b308af6a1d25..b845c8a9dfb3 100644 --- a/web/services/vms/images/devbox/README.md +++ b/web/services/vms/images/devbox/README.md @@ -1,72 +1,131 @@ -# cmux Cloud devbox image (freestyle) +# cmux Cloud devbox image (Freestyle) -The devbox definition for cmux Cloud VMs. The Dockerfile here is the source of -truth; `web/scripts/build-devbox-freestyle.ts` replays the same steps over -Freestyle exec (its build API has no COPY). Parity targets are the chatmux -devbox (`chatmux:infra/sandbox-images/Dockerfile`): same devtools, mise node/python/bun, -uv, gh, Chrome + cua-driver, pinned coding agents, ble.sh ghost text, -half-life prompt, seeded history, and the coderouter agent-config generator. +The devbox definition for cmux Cloud machines. The Dockerfile here is the +reference recipe (container providers used to build it directly); +`web/scripts/build-devbox-freestyle.ts` builds the same devbox on top of +the `freestyle/ubuntu` base VM and adds the **desktop layer** from +`desktop/`. Parity targets are the chatmux +devbox (`chatmux:infra/sandbox-images/Dockerfile`): same devtools, +node/python/bun, uv, gh, Chrome + cua-driver, pinned coding agents, ble.sh +ghost text, half-life prompt, seeded history, and the coderouter +agent-config generator. + +On Freestyle the toolchain is the base's, not mise: `freestyle/ubuntu` +already ships Node LTS under nvm (symlinked into `/usr/local/bin`), Bun, +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. `vm-devbox-image.test.ts` pins the shared files (`cmux-bashrc`, `agent-config.sh`, `seed-history`, `chrome-managed-policy.json`) to their chatmux counterparts, so edit both copies together. +## Desktop layer (`desktop/`, Freestyle) + +Ported from the retired Blaxel `sandbox/cmux-devbox` image: an +openbox/TigerVNC desktop with a tint2 dock (Chrome, Files, Ghostty), a CC0 +wallpaper, and noVNC on 6901. The contract the Mac CLI and any provider +heal depend on (`vm-devbox-desktop.test.ts` pins it): + +- `start-vnc.sh` runs as the work user `ubuntu` with `HOME=/home/ubuntu` + and `DISPLAY=:1`, so the desktop session is the same account terminals + and SSH land in; RFB on **5901 loopback-only** (no VNC auth; the only + ingress is a token-gated proxy in front of websockify), noVNC via + websockify on **6901** at `/`. +- The `cmux-desktop` systemd unit runs `cmux-desktop-boot`, which re-asserts + Chrome's pre-accepted first run and re-runs the idempotent `start-vnc.sh` + every 30 s. +- `ubuntu` has passwordless sudo, so coding agents' root-refusing modes + (`claude --dangerously-skip-permissions`) work. The Freestyle driver still + runs the cmux-tui daemon as root; moving sessions to `ubuntu` is a driver + change. +- Ghostty comes from a pinned community `.deb` for Ubuntu 24.04 + (`DEVBOX_GHOSTTY_DEB_URL` in `devbox-image-common.ts`). + +A desktop image is a superset of a base one, so one Freestyle snapshot is +registered under both kinds (`desktop` and `base`). `--no-desktop` bakes a +shell-only snapshot. Not yet wired: the Freestyle driver still runs the +daemon as root and exposes no ingress to 6901 (a `style.dev` or +`*.vm.cmux.sh` TLS rule is the intended path; see `desktopWrapper.ts`). + ## Session daemon: cmux-tui -Machines attach through the cmux-tui remote daemon on port 1337 (transport -`cmux-remote`, docs/cloud-cmux-tui-daemon.md). The binary is NOT baked: the -driver installs the pinned files.cmux.com build (sha256-verified) at create -time and heals pin drift on attach +Machines attach through the cmux-tui remote daemon on port 1337 +(transport `cmux-remote`, docs/cloud-cmux-tui-daemon.md). The binary is NOT +baked: the driver installs the pinned files.cmux.com build +(sha256-verified) at create time and heals pin drift on attach (`web/services/vms/drivers/cmuxTuiDaemon.ts`). The image ships only -`cmux-devbox-boot`, the supervisor that waits for the binary and restarts the -daemon. - -The baked `cmux-tui-daemon` systemd unit runs that supervisor with -`CMUX_TUI_REMOTE_WS_BIND=[::]:1337`. The public API does not provide an -authenticated HTTP proxy for arbitrary ports, so the route is the VM's stable -public IPv6 straight to the daemon (`ws://[ipv6]:1337/v1/link`, Noise -enrollment as the session gate). The listener must therefore be dual-stack. +`cmux-devbox-boot`, the supervisor that waits for the binary and restarts +the daemon, run by the baked `cmux-tui-daemon` systemd unit with +`CMUX_TUI_REMOTE_WS_BIND=[::]:1337` (the driver reaches the daemon at the +VM's IPv6 address, so the listener must be dual-stack). -Shells spawned by the daemon run as root with HOME=/root and get the bash -devshell (ble.sh ghost text, half-life prompt, seeded history) through the -`/etc/bash.bashrc` chain. +Shells spawned by the daemon get the bash devshell (ble.sh ghost text, +half-life prompt, seeded history) through the `/etc/bash.bashrc` chain. -## Bake +## Promote: bake, verify, record (one command) -Run from `web/`. The script refuses a stale checkout -(`CMUX_BAKE_ALLOW_BRANCH=1` for deliberate branch bakes). No local Docker -and no daemon build are needed. +The checked-in manifest (`web/services/vms/images/manifest.json`) is the +only source of truth for the image users get: the resolver serves the entry +flagged `defaultForKind`, in local dev and every deployed runtime alike, and +no env var selects or overrides it. `promote-devbox-image.ts` is the only +sanctioned writer: ```bash -FREESTYLE_API_KEY=... bun scripts/build-devbox-freestyle.ts cmux-devbox- +# from web/, with the Freestyle key in env +FREESTYLE_API_KEY=... bun run devbox:promote -- freestyle +FREESTYLE_API_KEY=... bun run devbox:promote -- freestyle --image sh-… # verify + record an existing snapshot ``` -Freestyle public-platform auth is `FREESTYLE_API_KEY`, or -`FREESTYLE_STACK_ACCESS_TOKEN` + `FREESTYLE_TEAM_ID`; the argument is the -snapshot slug (falls back to slugless on a collision) and the printed -`sh-…` id is the pointer to pin. Agent pins live only in the Dockerfile ARG -defaults; bump them together with `CMUX_IMAGE_EPOCH` and the chatmux -template. The cmux-tui pin comes from the artifacts manifest at deploy time -(`CMUX_VM_CMUX_TUI_MANIFEST_URL`), never from the image. +Snapshots are account-scoped: promote under the Freestyle account the +deployment's `FREESTYLE_API_KEY` belongs to, or the recorded id is +unreachable from production. + +It runs the stale-checkout preflight (`CMUX_BAKE_ALLOW_BRANCH=1` for +deliberate branch bakes), the bake, then `verify-devbox-image.ts`; only a +passing verify writes the manifest, appending one entry per kind flagged +`defaultForKind` while demoting the provider's previous defaults. Existing +entries are never removed, so rollback is a manifest revert. It also moves the +`cmux-devbox` snapshot slug onto the new id as a dashboard convenience; +production boots from the immutable `sh-…` id, never the slug. The last +stdout line is `IMAGE_ID `; `--out ` writes the summary. +Commit the manifest diff in a PR; merging it is the promotion. -## Verify +## Bake and verify by hand -The bake prints a `next` command. The verifier boots one machine, asserts the -toolchain, the exact agent pins, ghost text under a tmux PTY, and -byte-identical baked files, then replays the driver's create-time cmux-tui -bootstrap and asserts the daemon contract (session answering, port 1337 -listening, the systemd supervisor alive), and deletes the machine: +Each script refuses a stale checkout (`CMUX_BAKE_ALLOW_BRANCH=1` for +deliberate branch bakes). No local Docker and no daemon build are needed. ```bash -bun scripts/verify-devbox-image.ts freestyle +FREESTYLE_API_KEY=... bun scripts/build-devbox-freestyle.ts cmux-devbox- [--no-desktop] [--replace-slug] ``` -## Manifest +Freestyle bakes on `freestyle/ubuntu` (4 vCPU / 8 GiB / 32 GB): VMs always +boot at their snapshot's size and resizing is grow-only, so the builder's +shape is what every cmux Cloud machine gets. Freestyle snapshot slugs are +reassignable; the printed `sh-…` id is the pointer to pin. Agent pins live +only in the Dockerfile ARG defaults; bump them together with +`CMUX_IMAGE_EPOCH` and the chatmux template. The cmux-tui pin comes from +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, then replays +the driver's create-time cmux-tui bootstrap and asserts the daemon contract, +and deletes the sandbox: + +```bash +bun scripts/verify-devbox-image.ts freestyle +``` -Only after verify passes: take the `manifestEntry` the bake printed, set -`validationStatus` to `passed`, describe the validation in `notes`, and add -it to `web/services/vms/images/manifest.json` (append; never rewrite -existing entries). Point `FREESTYLE_SANDBOX_SNAPSHOT` at the new image where -it should serve, and flip `defaultForLocalDev` only from a validated entry. -Machines created from the old cmuxd-remote images cannot serve the +Only after verify passes may an entry carry `validationStatus: "passed"`; +`vm-image-manifest.test.ts` refuses a `defaultForKind` entry with any other +status. Machines created from the old cmuxd-remote images cannot serve the `cmux-remote` transport and need recreation on a devbox image. diff --git a/web/services/vms/images/devbox/cmux-motd b/web/services/vms/images/devbox/cmux-motd new file mode 100644 index 000000000000..5406c9907fc9 --- /dev/null +++ b/web/services/vms/images/devbox/cmux-motd @@ -0,0 +1,55 @@ +#!/bin/sh +# cmux Cloud login banner (/etc/update-motd.d/00-cmux). +# +# Rendered by pam_motd on SSH and console logins (sshd's PrintMotd is off; +# PAM assembles /run/motd.dynamic from this directory). The stock Ubuntu +# scripts in /etc/update-motd.d are left in place but made non-executable +# by the bake, and Freestyle's static /etc/motd is emptied, so this is the +# whole banner. Shells the cmux-tui daemon opens in panes do not go through +# PAM and never see this. +# +# This is `cmux welcome` (CLI/cmux.swift printWelcome) with the `cmux cloud` +# logo variant from the CLI's cloud SSH wrapper (CMUX_CLOUD_WELCOME): same +# true-color gradient, same shortcuts, same links. Keep the three in sync. +# Offline and instant: nothing here runs a tool. +reset='\033[0m' +bold='\033[1m' +tagline='\033[38;2;130;130;140m' +subdued='\033[2m' +c1='\033[38;2;0;212;255m' +c2='\033[38;2;24;181;250m' +c3='\033[38;2;48;150;245m' +c4='\033[38;2;72;119;241m' +c5='\033[38;2;96;88;239m' +c6='\033[38;2;110;73;238m' +c7='\033[38;2;124;58;237m' + +printf '\n' +printf "${c1} ::${reset}\n" +printf "${c2} :::: ${c1}c${c2}m${c3}u${c7}x cloud${reset}\n" +printf "${c3} ::::::${reset}\n" +printf "${c4} ::::::${reset} ${tagline}persistent cloud VM${reset}\n" +printf "${c5} ::::::${reset} ${tagline}ready for coding agents${reset}\n" +printf "${c6} ::::${reset}\n" +printf "${c7} ::${reset}\n" +printf '\n' +printf " ${bold}Shortcuts${reset}\n" +printf '\n' +printf " ${bold}⌘N${reset}${subdued} New workspace${reset}\n" +printf " ${bold}⌘T${reset}${subdued} New tab${reset}\n" +printf " ${bold}⌘P${reset}${subdued} Go to workspace${reset}\n" +printf " ${bold}⌘B${reset}${subdued} Toggle Left Sidebar${reset}\n" +printf " ${bold}⌘⌥B${reset}${subdued} Toggle Right Sidebar${reset}\n" +printf " ${bold}⌘D${reset}${subdued} Split right${reset}\n" +printf " ${bold}⌘⇧D${reset}${subdued} Split down${reset}\n" +printf " ${bold}⌘⇧P${reset}${subdued} Command palette${reset}\n" +printf " ${bold}⌘⇧R${reset}${subdued} Rename workspace${reset}\n" +printf " ${bold}⌘⇧L${reset}${subdued} New browser${reset}\n" +printf " ${bold}⌘⇧U${reset}${subdued} Jump to latest unread${reset}\n" +printf " ${bold}⌥⌘U${reset}${subdued} Toggle unread${reset}\n" +printf '\n' +printf " ${bold}Docs${reset}${subdued} https://cmux.com/docs${reset}\n" +printf " ${bold}Discord${reset}${subdued} https://discord.gg/xsgFEVrWCZ${reset}\n" +printf " ${bold}GitHub${reset}${subdued} https://github.com/manaflow-ai/cmux (please leave a star ⭐)${reset}\n" +printf " ${bold}Email${reset}${subdued} founders@manaflow.com${reset}\n" +printf '\n' diff --git a/web/services/vms/images/devbox/desktop/WALLPAPER.md b/web/services/vms/images/devbox/desktop/WALLPAPER.md new file mode 100644 index 000000000000..755b6fda5310 --- /dev/null +++ b/web/services/vms/images/devbox/desktop/WALLPAPER.md @@ -0,0 +1,18 @@ +# cmux-devbox wallpaper (copied from the chatmux desktop image) + +`wallpaper.jpg` — mountain lake landscape. + +- Source: Wikimedia Commons, + "File:Landscape-mountains-nature-lake (24326735085).jpg" + https://commons.wikimedia.org/wiki/File:Landscape-mountains-nature-lake_(24326735085).jpg +- Original file: + https://upload.wikimedia.org/wikipedia/commons/5/5c/Landscape-mountains-nature-lake_%2824326735085%29.jpg +- Author: www.Pixel.la Free Stock Photos (via Flickr, + https://www.flickr.com/photos/137643065@N06/24326735085/) +- License: **CC0 1.0 Universal (public domain dedication)** + https://creativecommons.org/publicdomain/zero/1.0/deed.en + (license verified 2026-08-12 via the Commons imageinfo API: + `LicenseShortName: CC0`) +- Processing: original 5966x3978 downscaled to 2560x1707 JPEG (quality 80) + with macOS `sips`; committed into the repo so image builds are hermetic + (no network fetch at build time). diff --git a/web/services/vms/images/devbox/desktop/cmux-desktop-boot b/web/services/vms/images/devbox/desktop/cmux-desktop-boot new file mode 100644 index 000000000000..92ff056e5a04 --- /dev/null +++ b/web/services/vms/images/devbox/desktop/cmux-desktop-boot @@ -0,0 +1,17 @@ +#!/bin/sh +# cmux devbox desktop supervisor (/usr/local/bin/cmux-desktop-boot). +# +# Runs as the work user `ubuntu` (the cmux-desktop systemd unit sets +# User=ubuntu, HOME=/home/ubuntu, DISPLAY=:1) and keeps the desktop up: start-vnc.sh +# is idempotent, so re-running it every 30 s against a healthy desktop is a +# cheap no-op probe and brings back any component that died mid-life. +# +# The "First Run" marker pre-accepts Chrome's first-run/ToS dialog, so the +# dock's Chrome opens straight to a page on a fresh machine and in anything +# resumed from its snapshot. +mkdir -p "$HOME/.config/google-chrome" 2>/dev/null +touch "$HOME/.config/google-chrome/First Run" 2>/dev/null +while true; do + bash /usr/local/bin/start-vnc.sh || true + sleep 30 +done diff --git a/web/services/vms/images/devbox/desktop/cmux-desktop.service b/web/services/vms/images/devbox/desktop/cmux-desktop.service new file mode 100644 index 000000000000..118acb719f94 --- /dev/null +++ b/web/services/vms/images/devbox/desktop/cmux-desktop.service @@ -0,0 +1,17 @@ +[Unit] +Description=cmux devbox desktop (TigerVNC + openbox + noVNC on 6901) +After=network.target + +[Service] +Type=simple +User=ubuntu +Environment=HOME=/home/ubuntu +Environment=USER=ubuntu +Environment=DISPLAY=:1 +WorkingDirectory=/home/ubuntu +ExecStart=/usr/local/bin/cmux-desktop-boot +Restart=always +RestartSec=2 + +[Install] +WantedBy=multi-user.target diff --git a/web/services/vms/images/devbox/desktop/ghostty-cmux.desktop b/web/services/vms/images/devbox/desktop/ghostty-cmux.desktop new file mode 100644 index 000000000000..4d37daff2428 --- /dev/null +++ b/web/services/vms/images/devbox/desktop/ghostty-cmux.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Version=1.0 +Name=Ghostty +Comment=Terminal emulator +Exec=/usr/bin/ghostty +Terminal=false +Icon=/etc/cmux/icons/ghostty.png +Type=Application +Categories=System;TerminalEmulator; +StartupNotify=true diff --git a/web/services/vms/images/devbox/desktop/google-chrome-cmux.desktop b/web/services/vms/images/devbox/desktop/google-chrome-cmux.desktop new file mode 100644 index 000000000000..a0d9f534b3ee --- /dev/null +++ b/web/services/vms/images/devbox/desktop/google-chrome-cmux.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Version=1.0 +Name=Google Chrome +Comment=Browse the web +Exec=/usr/bin/google-chrome-stable --no-first-run --no-default-browser-check --disable-session-crashed-bubble %U +Terminal=false +Icon=/etc/cmux/icons/google-chrome.png +Type=Application +Categories=Network;WebBrowser; +StartupNotify=true diff --git a/web/services/vms/images/devbox/desktop/start-vnc.sh b/web/services/vms/images/devbox/desktop/start-vnc.sh new file mode 100644 index 000000000000..5ea53e890f72 --- /dev/null +++ b/web/services/vms/images/devbox/desktop/start-vnc.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# Desktop starter for the cmux devbox image (/usr/local/bin/start-vnc.sh). +# CONTRACT FILE: the desktop is reached on two fixed ports, and the Mac CLI +# (cloudVMDesktopPort) plus any provider heal command depend on them: +# - RFB on 5901, loopback only, no VNC-level auth (the only ingress is a +# token-gated proxy in front of websockify; a VNC password would be a +# second prompt noVNC's autoconnect cannot answer). +# - noVNC web client via websockify on 6901, at `/`. +# It runs as the work user `ubuntu` with HOME=/home/ubuntu and DISPLAY=:1 +# (cmux-desktop-boot under the cmux-desktop systemd unit on Freestyle; a +# provider driver's heal command runs exactly the same invocation), so the +# desktop session is the same account terminals and SSH land in. This +# path, user, display, and ports must not change without a matching driver +# change. tests/vm-devbox-desktop.test.ts pins the contract. +# +# Idempotent: every component is guarded by a liveness probe, so re-running +# against a healthy desktop starts nothing. Starts everything in the background +# and exits; the supervisor loop re-invokes it. +set -u + +DISPLAY="${DISPLAY:-:1}" +export DISPLAY +GEOMETRY="${CMUX_VNC_GEOMETRY:-1440x900}" +# Never let an unwritable HOME keep the desktop down: fall back to a per-user +# tmp dir for logs. +LOG_DIR="$HOME/.cmux/desktop-logs" +mkdir -p "$LOG_DIR" 2>/dev/null || { LOG_DIR="/tmp/cmux-desktop-logs-$(id -u)"; mkdir -p "$LOG_DIR"; } + +# The supervisor re-runs this every 30 s, so cap each component log here: a +# crash-looping component must not grow its log without bound. +for log in "$LOG_DIR"/*.log; do + [ -f "$log" ] || continue + if [ "$(wc -c <"$log" 2>/dev/null || echo 0)" -gt 1048576 ]; then + tail -c 65536 "$log" > "$log.tmp" 2>/dev/null && mv "$log.tmp" "$log" + fi +done + +VNC_BIN="$(command -v Xvnc || command -v Xtigervnc)" || exit 0 + +listening() { ss -tln 2>/dev/null | grep -q ":$1 "; } + +# TigerVNC on :1, RFB on 5901, loopback only. +if ! listening 5901; then + "$VNC_BIN" "$DISPLAY" \ + -geometry "$GEOMETRY" \ + -depth 24 \ + -rfbport 5901 \ + -localhost \ + -SecurityTypes None \ + -AlwaysShared \ + >>"$LOG_DIR/xvnc.log" 2>&1 & + for _ in $(seq 1 50); do listening 5901 && break; sleep 0.2; done +fi + +if ! pgrep -u "$(id -u)" -x openbox >/dev/null 2>&1; then + dbus-launch openbox >>"$LOG_DIR/openbox.log" 2>&1 & +fi + +set_wallpaper() { + if [ -f /usr/share/backgrounds/cmux/wallpaper.jpg ] && command -v feh >/dev/null 2>&1; then + feh --no-fehbg --bg-fill /usr/share/backgrounds/cmux/wallpaper.jpg >/dev/null 2>&1 \ + || xsetroot -solid '#1f2430' >/dev/null 2>&1 || true + else + xsetroot -solid '#1f2430' >/dev/null 2>&1 || true + fi +} +set_wallpaper + +if ! pgrep -u "$(id -u)" -x tint2 >/dev/null 2>&1; then + tint2 -c /etc/cmux/tint2rc >>"$LOG_DIR/tint2.log" 2>&1 & +fi + +# noVNC's remote resize grows the display live, but the root pixmap keeps its +# old size (X tiles it: the doubled-wallpaper bug) and tint2 keeps its old +# strut. Re-fill the wallpaper and nudge tint2 whenever the geometry changes. +# `bash -c '…' cmux-desktop-resize-watch` puts the marker in $0 so the pgrep +# guard finds the loop on the next idempotent pass. +if ! pgrep -u "$(id -u)" -f cmux-desktop-resize-watch >/dev/null 2>&1; then + bash -c ' + last="" + while :; do + now=$(xdpyinfo 2>/dev/null | awk "/dimensions:/ {print \$2}") + if [ -n "$now" ] && [ "$now" != "$last" ]; then + if [ -n "$last" ]; then + feh --no-fehbg --bg-fill /usr/share/backgrounds/cmux/wallpaper.jpg >/dev/null 2>&1 || true + pkill -USR1 -U "$(id -u)" -x tint2 >/dev/null 2>&1 || true + fi + last="$now" + fi + sleep 2 + done + ' cmux-desktop-resize-watch >>"$LOG_DIR/resize-watch.log" 2>&1 & +fi + +# noVNC web client + websocket proxy on 6901 (the CLI's cloudVMDesktopPort). +if ! listening 6901; then + websockify --web /usr/share/novnc --heartbeat 30 0.0.0.0:6901 127.0.0.1:5901 \ + >>"$LOG_DIR/websockify.log" 2>&1 & +fi + +exit 0 diff --git a/web/services/vms/images/devbox/desktop/thunar-cmux.desktop b/web/services/vms/images/devbox/desktop/thunar-cmux.desktop new file mode 100644 index 000000000000..be8ed36fe40d --- /dev/null +++ b/web/services/vms/images/devbox/desktop/thunar-cmux.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Version=1.0 +Name=Files +Comment=Browse the filesystem +Exec=/usr/bin/thunar %U +Terminal=false +Icon=/etc/cmux/icons/thunar.png +Type=Application +Categories=System;Utility;FileManager; +StartupNotify=true diff --git a/web/services/vms/images/devbox/desktop/tint2rc b/web/services/vms/images/devbox/desktop/tint2rc new file mode 100644 index 000000000000..a311d06d851c --- /dev/null +++ b/web/services/vms/images/devbox/desktop/tint2rc @@ -0,0 +1,41 @@ +# tint2 dock for the cmux devbox desktop: launchers (Chrome, Thunar, Ghostty) +# and a taskbar, no clock. Bottom panel, dark, matches the chatmux desktop +# layout. Launchers and icons live under /etc/cmux so the dock never depends +# on a distro's /usr/share/applications or icon-theme layout. +# +# ORDER MATTERS. tint2 resolves every `*_background_id = N` while it parses, +# against the backgrounds defined ABOVE that line (id 0 is the built-in +# transparent one; each `rounded =` opens the next). A forward reference clamps +# to -1 and hands the panel a garbage Background (out-of-bounds g_array_index): +# garbage borders drive the launcher icon size negative, every scaled icon comes +# back NULL, and the whole dock paints nothing (the 2026-08-27 invisible-toolbar +# regression). Keep the background blocks first; tests/vm-devbox-desktop.test.ts +# pins this. +# Background 1: panel +rounded = 0 +border_width = 0 +background_color = #14161c 90 +border_color = #14161c 0 + +panel_items = LT +panel_position = bottom center horizontal +panel_size = 100% 36 +panel_margin = 0 0 +panel_padding = 6 3 6 +panel_background_id = 1 +panel_layer = top +wm_menu = 1 + +launcher_padding = 4 2 8 +launcher_icon_size = 26 +launcher_item_app = /etc/cmux/apps/google-chrome-cmux.desktop +launcher_item_app = /etc/cmux/apps/thunar-cmux.desktop +launcher_item_app = /etc/cmux/apps/ghostty-cmux.desktop + +taskbar_mode = single_desktop +taskbar_padding = 6 2 6 +task_maximum_size = 220 30 +task_padding = 8 3 +task_text = 1 +task_icon = 1 +task_font_color = #d8dee9 100 diff --git a/web/services/vms/images/devbox/desktop/wallpaper.jpg b/web/services/vms/images/devbox/desktop/wallpaper.jpg new file mode 100644 index 000000000000..634f5df460e2 Binary files /dev/null and b/web/services/vms/images/devbox/desktop/wallpaper.jpg differ diff --git a/web/services/vms/images/manifest.json b/web/services/vms/images/manifest.json index d4b868b68427..10c3e50d4cfe 100644 --- a/web/services/vms/images/manifest.json +++ b/web/services/vms/images/manifest.json @@ -65,7 +65,7 @@ "version": "freestyle-cmux-devbox-20260902c", "imageId": "sh-940ec3bc46224c019e5e8d9a97053293", "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", - "defaultForLocalDev": false, + "defaultForLocalDev": true, "kind": "base", "cmuxdRemoteCommit": "none-cmux-tui", "builtAt": "2026-09-02T08:33:56.761Z", @@ -78,7 +78,52 @@ "agent-browser": "0.35.2" }, "validationStatus": "passed", - "notes": "cmux devbox epoch 2026-09-01-r3, exec-replay bake of the shared devbox on the Freestyle public platform (api.freestyle.sh, slug cmux-devbox-20260902c) from main 2526fbf0f2. Validated 2026-09-02 with scripts/verify-devbox-image.ts freestyle: agent pins, mise toolchain, Chrome + cua-driver, ble.sh ghost text and quiet marks, byte-identical baked files, explicit Codex supports_websockets=false config, cmux-tui daemon bootstrap (session answering, [::]:1337 listening, cmux-tui-daemon systemd unit active). cmux-tui transport over the VM public IPv6." + "notes": "cmux devbox epoch 2026-09-01-r3, exec-replay bake of the shared devbox on the Freestyle public platform (api.freestyle.sh, slug cmux-devbox-20260902c) from main 2526fbf0f2. Validated 2026-09-02 with scripts/verify-devbox-image.ts freestyle: agent pins, mise toolchain, Chrome + cua-driver, ble.sh ghost text and quiet marks, byte-identical baked files, explicit Codex supports_websockets=false config, cmux-tui daemon bootstrap (session answering, [::]:1337 listening, cmux-tui-daemon systemd unit active). cmux-tui transport over the VM public IPv6.", + "defaultForKind": true + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-20260902h", + "imageId": "sh-1d66b9cad53a4811b5f63a4cae0b3faf", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "defaultForLocalDev": false, + "kind": "desktop", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "a894e69ba5be298d82041469e306207eda581798", + "builtAt": "2026-09-02T09:42:08.949Z", + "builderScriptVersion": "4ebdd1704b123e776199b2a7929f82d77190377139a500b17e57f69f1c3c6bf0", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.252", + "@openai/codex": "0.151.0", + "opencode-ai": "1.18.25", + "@earendil-works/pi-coding-agent": "0.84.4", + "agent-browser": "0.35.2" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-01-r3 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu: 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; cmux-tui transport. Validated 2026-09-02 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Baked on the Freestyle team of the contributor (no-limits), not on cmux's account: snapshots are account-scoped, so this id is a validated reference bake, not a default. Re-bake with `bun run devbox:promote -- freestyle` under cmux's FREESTYLE_API_KEY to promote the desktop devbox.", + "defaultForKind": false + }, + { + "provider": "freestyle", + "version": "freestyle-cmux-devbox-20260902h-base", + "imageId": "sh-1d66b9cad53a4811b5f63a4cae0b3faf", + "envVar": "FREESTYLE_SANDBOX_SNAPSHOT", + "defaultForLocalDev": false, + "kind": "base", + "cmuxdRemoteCommit": "none-cmux-tui", + "repoCommit": "a894e69ba5be298d82041469e306207eda581798", + "builtAt": "2026-09-02T09:42:08.949Z", + "builderScriptVersion": "4ebdd1704b123e776199b2a7929f82d77190377139a500b17e57f69f1c3c6bf0", + "agentToolResolvedVersions": { + "@anthropic-ai/claude-code": "2.1.252", + "@openai/codex": "0.151.0", + "opencode-ai": "1.18.25", + "@earendil-works/pi-coding-agent": "0.84.4", + "agent-browser": "0.35.2" + }, + "validationStatus": "passed", + "notes": "cmux devbox epoch 2026-09-01-r3 Devbox on the Freestyle public platform (api.freestyle.sh) from freestyle/ubuntu: 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; cmux-tui transport. Validated 2026-09-02 with verify-devbox-image.ts by promote-devbox-image.ts (toolchain, agent pins, daemon contract, desktop on 5901/6901). Baked on the Freestyle team of the contributor (no-limits), not on cmux's account: snapshots are account-scoped, so this id is a validated reference bake, not a default. Re-bake with `bun run devbox:promote -- freestyle` under cmux's FREESTYLE_API_KEY to promote the desktop devbox.", + "defaultForKind": false } ] } diff --git a/web/services/vms/images/resolver.ts b/web/services/vms/images/resolver.ts index b7c84382b2a8..d400b1b6f2a0 100644 --- a/web/services/vms/images/resolver.ts +++ b/web/services/vms/images/resolver.ts @@ -1,11 +1,12 @@ import type { ProviderId } from "../drivers"; -import { allowUnmanifestedImages, isDeployedRuntime, type VmRuntimeEnv } from "../config"; +import { allowUnmanifestedImages, type VmRuntimeEnv } from "../config"; import { VmImageConfigError, type VmImageSource } from "../errors"; import manifest from "./manifest.json"; /** * What a machine is for. Clients ask for a kind instead of pinning an image id so - * the server (env + manifest) stays the only place that knows concrete image ids. + * the server (the checked-in manifest) stays the only place that knows concrete + * image ids. */ export type VmImageKind = "desktop" | "base"; @@ -19,13 +20,17 @@ export type VmImageManifestEntry = { readonly provider: ProviderId; readonly version: string; readonly imageId: string; + /** Legacy: the env var that used to select this image. Nothing reads it any more. */ readonly envVar: string; + /** Legacy: local dev now uses the same `defaultForKind` entry as production. */ readonly defaultForLocalDev?: boolean; /** Which machine kind this image serves. Missing means "base" unless the id says otherwise. */ readonly kind?: VmImageKind; - /** The manifest default for `kind` when neither the client nor the env picks an image. */ + /** The image served for `kind` when the client does not name one. Exactly one per provider and kind. */ readonly defaultForKind?: boolean; readonly cmuxdRemoteCommit: string; + /** The cmux commit whose devbox definition produced this image. */ + readonly repoCommit?: string; readonly builtAt: string; readonly builderScriptVersion: string; readonly agentToolResolvedVersions?: Record; @@ -50,28 +55,6 @@ const typedManifest = manifest as { readonly images: readonly VmImageManifestEntry[]; }; -/** - * Env var that selects the provider's image. Desktop images get their own - * `_DESKTOP_IMAGE` selector (no provider ships a desktop image today); providers - * with a single template/snapshot variable use it for both kinds. - */ -export function providerImageEnvKey(provider: ProviderId, kind?: VmImageKind): string { - const baseKey = providerBaseImageEnvKey(provider); - if (kind === "desktop" && baseKey.endsWith("_IMAGE")) { - return `${baseKey.slice(0, -"_IMAGE".length)}_DESKTOP_IMAGE`; - } - return baseKey; -} - -function providerBaseImageEnvKey(provider: ProviderId): string { - switch (provider) { - case "freestyle": - return "FREESTYLE_SANDBOX_SNAPSHOT"; - default: - return assertNever(provider); - } -} - export function listVmImageManifestEntries(): readonly VmImageManifestEntry[] { return typedManifest.images; } @@ -83,10 +66,32 @@ export function listVmImageIds(provider: ProviderId): string[] { .map((entry) => entry.imageId); } -export function findVmImageManifestEntry(provider: ProviderId, image: string): VmImageManifestEntry | null { - return typedManifest.images.find((candidate) => +/** + * The manifest entry for an image id or version. One image can be listed + * under more than one kind (a desktop image is a superset of a base one, so + * the Freestyle devbox serves both); with a `kind`, the entry of that kind + * wins, otherwise the first listing does. + */ +export function findVmImageManifestEntry( + provider: ProviderId, + image: string, + kind?: VmImageKind, +): VmImageManifestEntry | null { + const matches = typedManifest.images.filter((candidate) => candidate.provider === provider && (candidate.imageId === image || candidate.version === image) + ); + if (kind !== undefined) { + const ofKind = matches.find((candidate) => deriveVmImageKind(candidate, image) === kind); + if (ofKind) return ofKind; + } + return matches[0] ?? null; +} + +/** The manifest entry flagged `defaultForKind` for `kind`, if the provider has one. */ +export function findVmImageKindDefault(provider: ProviderId, kind: VmImageKind): VmImageManifestEntry | null { + return typedManifest.images.find((entry) => + entry.provider === provider && (entry.kind ?? "base") === kind && entry.defaultForKind === true ) ?? null; } @@ -105,8 +110,8 @@ export function vmImageKindFor(provider: ProviderId, image: string): VmImageKind } /** - * The image each kind would resolve to for `provider` in this environment. - * Kinds with nothing configured are omitted, so clients can offer only kinds that work. + * The image each kind would resolve to for `provider`. + * Kinds with no manifest default are omitted, so clients can offer only kinds that work. */ export function listVmImageKinds( provider: ProviderId, @@ -148,12 +153,14 @@ export function inferVmProviderForImage(requestedImage: string | undefined): Pro } /** - * Resolution order: - * 1. an explicit `requestedImage` (must be in the manifest unless unmanifested images are allowed); - * 2. the provider's env selector for `kind` (operator configuration, accepted even when unmanifested); - * for `desktop`, the provider's generic selector also counts when it names a desktop image; - * 3. with a `kind`: the manifest entry flagged `defaultForKind` for that kind (also in deployed runtimes); - * 4. without a `kind`: deployed runtimes throw; local dev falls back to `defaultForLocalDev`. + * The checked-in manifest is the only source of truth for images; no env var + * selects or overrides one. Resolution order: + * 1. an explicit `requestedImage` (must be in the manifest unless unmanifested + * images are allowed, which they are outside deployed runtimes or with + * CMUX_VM_ALLOW_UNMANIFESTED_IMAGES=1); + * 2. the manifest entry flagged `defaultForKind` for the requested kind + * (`base` when the client did not ask for a kind). + * Rollback is a manifest change (revert the promotion PR) and a deploy. */ export function resolveVmImage( provider: ProviderId, @@ -174,118 +181,37 @@ export function resolveVmImage( const requested = requestedImage?.trim(); if (requested) { - return resolveKnownOrAllowed(provider, requested, undefined, env, kind); - } - - const envVar = providerImageEnvKey(provider, kind); - const configured = env[envVar]?.trim(); - if (configured) { - // An operator's generic selector naming an image of another kind is not a - // misconfiguration for this request: deployments from before image kinds - // existed set the single selector to the desktop image. Serve the - // requested kind from the manifest defaults instead of failing the - // create on the mismatch. Client-requested images keep the strict check. - if (kind !== undefined) { - const entry = findVmImageManifestEntry(provider, configured); - if (entry && deriveVmImageKind(entry, configured) !== kind) { - return resolveByKind(provider, kind, envVar, env); - } - } - return resolveKnownOrAllowed(provider, configured, envVar, env, kind); - } - - if (kind !== undefined) { - return resolveByKind(provider, kind, envVar, env); - } - - if (isDeployedRuntime(env)) { - throw new VmImageConfigError({ - provider, - envVar, - source: "env", - allowedImages: listVmImageIds(provider), - reason: `${envVar} is required in deployed environments`, - }); - } - - const localDefault = typedManifest.images.find((entry) => - entry.provider === provider && entry.defaultForLocalDev === true - ); - if (!localDefault) { - throw new VmImageConfigError({ - provider, - envVar, - source: "default", - allowedImages: listVmImageIds(provider), - reason: `no local default image is recorded for ${provider}`, - }); + return resolveRequested(provider, requested, env, kind); } - return selectionFromEntry(localDefault); -} -function resolveByKind( - provider: ProviderId, - kind: VmImageKind, - envVar: string, - env: VmRuntimeEnv, -): VmImageSelection { - // The kind-specific selector is unset. The provider's generic selector still - // counts when the image it names is of the requested kind (e.g. a deployment - // whose FREESTYLE_SANDBOX_SNAPSHOT already names a desktop image). - const genericEnvVar = providerImageEnvKey(provider); - if (genericEnvVar !== envVar) { - const generic = env[genericEnvVar]?.trim(); - if (generic) { - const entry = findVmImageManifestEntry(provider, generic); - if (entry && deriveVmImageKind(entry, generic) === kind) { - return selectionFromEntry(entry); - } - } - } - - const kindDefault = typedManifest.images.find((entry) => - entry.provider === provider && entry.kind === kind && entry.defaultForKind === true - ); + const effectiveKind = kind ?? "base"; + const kindDefault = findVmImageKindDefault(provider, effectiveKind); if (kindDefault) return selectionFromEntry(kindDefault); - if (!isDeployedRuntime(env)) { - const localDefault = typedManifest.images.find((entry) => - entry.provider === provider && entry.defaultForLocalDev === true - ); - if (localDefault && deriveVmImageKind(localDefault, localDefault.imageId) === kind) { - return selectionFromEntry(localDefault); - } - } - throw new VmImageConfigError({ provider, - envVar, - kind, + kind: effectiveKind, source: "default", allowedImages: listVmImageIds(provider), - reason: `no ${kind} image is configured for ${provider}: set ${envVar} or record a ${kind} manifest default`, + reason: `no ${effectiveKind} image is recorded as the manifest default for ${provider}: promote one (bun run devbox:promote -- ${provider})`, }); } -const warnedUnmanifestedEnvImages = new Set(); - -function resolveKnownOrAllowed( +function resolveRequested( provider: ProviderId, image: string, - envVar: string | undefined, env: VmRuntimeEnv, kind: VmImageKind | undefined, ): VmImageSelection { - const entry = findVmImageManifestEntry(provider, image); + const entry = findVmImageManifestEntry(provider, image, kind); if (entry) { const selection = selectionFromEntry(entry); if (kind !== undefined && selection.kind !== kind) { throw new VmImageConfigError({ provider, image, - envVar, kind, - source: envVar === undefined ? "request" : "env", + source: "request", allowedImages: listVmImageIds(provider), reason: `${image} is a ${selection.kind} image, not a ${kind} image`, }); @@ -293,21 +219,6 @@ function resolveKnownOrAllowed( return selection; } - // An image named by the provider's env var is operator configuration: the - // manifest may lag behind a deployment, and refusing every create until the - // manifest catches up is worse than running the configured image. Only a - // client-requested image keeps the strict manifest check. - if (envVar !== undefined) { - const warnKey = `${provider}:${envVar}:${image}`; - if (!warnedUnmanifestedEnvImages.has(warnKey)) { - warnedUnmanifestedEnvImages.add(warnKey); - console.warn( - `[vm-image-resolver] ${envVar}=${image} is not listed in the Cloud VM image manifest for ${provider}; using it as configured (imageVersion unknown)`, - ); - } - return { provider, image, imageVersion: null, manifestEntry: null, kind: kind ?? deriveVmImageKind(null, image) }; - } - if (allowUnmanifestedImages(env)) { return { provider, @@ -321,7 +232,6 @@ function resolveKnownOrAllowed( throw new VmImageConfigError({ provider, image, - envVar, kind, source: "request", allowedImages: listVmImageIds(provider), @@ -333,25 +243,24 @@ export type VmImageConfigErrorReport = { readonly message: string; readonly action: string; /** - * Client-safe details. Provider names, env var names, image ids, and manifest - * wording stay out of responses (see `expectNoCloudVmImplementationLeaks` in + * Client-safe details. Provider names, image ids, and manifest wording stay + * out of responses (see `expectNoCloudVmImplementationLeaks` in * tests/vm-route-auth.test.ts); those go to the operator log instead. */ readonly details: { - /** True only when the client asked for a specific image; false for env/manifest failures. */ + /** True only when the client asked for a specific image; false for manifest-default failures. */ readonly imageRequested: boolean; /** The kind the client asked for, when it asked by kind. */ readonly kind?: string; - /** Which configuration failed: the request body, an env selector, or the server's default selection. */ + /** Which configuration failed: the request body or the server's manifest default. */ readonly source: VmImageSource; - /** Kinds this environment can serve right now, so a client can offer a working alternative. */ + /** Kinds this deployment can serve right now, so a client can offer a working alternative. */ readonly allowedKinds: readonly VmImageKind[]; }; /** What an operator needs to fix the deployment; logged, never returned to clients. */ readonly operator: { readonly provider: ProviderId; readonly image?: string; - readonly envVar?: string; readonly kind?: string; readonly source: VmImageSource; readonly allowedImages: readonly string[]; @@ -362,7 +271,7 @@ export type VmImageConfigErrorReport = { /** * Shared wording and logging for `vm_image_config_error`, so create, base open, * and base reset describe the same failure the same way. Logs the operator - * detail (provider, env var, allowed image ids, reason) once per call. + * detail (provider, allowed image ids, reason) once per call. */ export function reportVmImageConfigError( err: VmImageConfigError, @@ -381,15 +290,14 @@ export function reportVmImageConfigError( action = `Pass \`kind\` as one of ${VM_IMAGE_KINDS.join(", ")}, or omit it to use the default Cloud VM image.`; } else if (err.kind !== undefined) { message = `No ${err.kind} Cloud VM image is available in this environment.`; - action = `Retry with a different \`kind\` (available: ${kindList}), or ask an admin to configure a ${err.kind} Cloud VM image.`; + action = `Retry with a different \`kind\` (available: ${kindList}), or ask an admin to promote a ${err.kind} Cloud VM image.`; } else { message = "The default Cloud VM image is not configured in this environment."; - action = "Ask an admin to configure the default Cloud VM image, then retry."; + action = "Ask an admin to promote a default Cloud VM image, then retry."; } const operator = { provider: err.provider, image: err.image, - envVar: err.envVar, kind: err.kind, source: err.source, allowedImages: err.allowedImages, @@ -413,7 +321,3 @@ function selectionFromEntry(entry: VmImageManifestEntry): VmImageSelection { kind: deriveVmImageKind(entry, entry.imageId), }; } - -function assertNever(value: never): never { - throw new Error(`unsupported VM provider: ${String(value)}`); -} diff --git a/web/tests/cloud-vm-env-audit.test.ts b/web/tests/cloud-vm-env-audit.test.ts index 32568c72bcd4..9bbd67d68f64 100644 --- a/web/tests/cloud-vm-env-audit.test.ts +++ b/web/tests/cloud-vm-env-audit.test.ts @@ -34,6 +34,8 @@ type Manifest = { imageId: string; envVar: string; validationStatus: string; + kind?: string; + defaultForKind?: boolean; }>; }; @@ -50,19 +52,27 @@ const realManifest = JSON.parse( ), ) as Manifest; +// The manifest's validated base default (defaultForKind): what deployed +// runtimes serve when FREESTYLE_SANDBOX_SNAPSHOT is unset. +const freestyleBaseDefault = realManifest.images.find( + (entry) => entry.provider === "freestyle" && (entry.kind ?? "base") === "base" && entry.defaultForKind, +)!; + describe("cloud VM provider coherence audit", () => { test("an env default naming a removed provider fails on the code-default leg", () => { // The 2026-08-26 outage shape, and now also the stale-env shape: the // deployed CMUX_VM_DEFAULT_PROVIDER still names a provider that has been // removed, while shipped clients send the code default's image ids. The - // old key-presence audit passed this env. + // manifest default covers the missing snapshot env var; the missing API + // key still makes the code default unprovisionable. const result = auditCloudVmProviderCoherence( { CMUX_VM_DEFAULT_PROVIDER: "e2b" }, realManifest, ) as Coherence; expect(result.selected?.provider).toBe("e2b"); expect(result.codeDefault?.provider).toBe("freestyle"); - expect(result.problems.join("\n")).toContain("FREESTYLE_SANDBOX_SNAPSHOT is not set"); + expect(result.codeDefault).toMatchObject({ image: freestyleBaseDefault.imageId, imageSource: "manifest" }); + expect(result.problems.join("\n")).toContain("FREESTYLE_API_KEY is not set"); }); test("no default provider set means the code default (freestyle) must be ready", () => { @@ -72,16 +82,48 @@ describe("cloud VM provider coherence audit", () => { ) as Coherence; expect(result.selected?.provider).toBe("freestyle"); expect(result.codeDefault).toBeNull(); - expect(result.problems.join("\n")).toContain("FREESTYLE_SANDBOX_SNAPSHOT is not set"); + expect(result.selected).toMatchObject({ image: freestyleBaseDefault.imageId, imageSource: "manifest" }); expect(result.problems.join("\n")).toContain("FREESTYLE_API_KEY"); }); + test("the manifest's validated base default is the deployed image", () => { + // The committed manifest is the only source of truth (resolver + // defaultForKind). A clean env is one with the API key set. + const result = auditProviderReadiness("freestyle", { FREESTYLE_API_KEY: "x" }, realManifest) as { + image: string | null; + imageSource: string; + problems: string[]; + }; + expect(result).toMatchObject({ image: freestyleBaseDefault.imageId, imageSource: "manifest", problems: [] }); + }); + + test("a manifest without a validated base default fails closed", () => { + const stripped = { + images: realManifest.images.map((entry) => ({ ...entry, defaultForKind: false })), + }; + const result = auditProviderReadiness("freestyle", { FREESTYLE_API_KEY: "x" }, stripped) as { + problems: string[]; + }; + expect(result.problems.join("\n")).toContain("the manifest has no validated base default for freestyle"); + const unvalidated = { + images: realManifest.images.map((entry) => + entry.defaultForKind ? { ...entry, validationStatus: "unknown" } : entry, + ), + }; + const bad = auditProviderReadiness("freestyle", { FREESTYLE_API_KEY: "x" }, unvalidated) as { + image: string | null; + problems: string[]; + }; + expect(bad.problems.join("\n")).toMatch(/manifest default .* has validationStatus unknown, not passed/); + expect(bad.problems.join("\n")).toContain("the manifest has no validated base default for freestyle"); + expect(bad.image).toBeNull(); + }); + test("the validated public-platform freestyle devbox passes as the default provider", () => { const result = auditCloudVmProviderCoherence( { CMUX_VM_DEFAULT_PROVIDER: "freestyle", CMUX_VM_FREESTYLE_ENABLED: "1", - FREESTYLE_SANDBOX_SNAPSHOT: "sh-940ec3bc46224c019e5e8d9a97053293", FREESTYLE_API_KEY: "x", }, realManifest, @@ -96,7 +138,6 @@ describe("cloud VM provider coherence audit", () => { { CMUX_VM_DEFAULT_PROVIDER: "freestyle", CMUX_VM_FREESTYLE_ENABLED: "0", - FREESTYLE_SANDBOX_SNAPSHOT: "sh-940ec3bc46224c019e5e8d9a97053293", FREESTYLE_API_KEY: "x", }, realManifest, @@ -104,29 +145,18 @@ describe("cloud VM provider coherence audit", () => { expect(result.problems.join("\n")).toContain("CMUX_VM_FREESTYLE_ENABLED disables provider freestyle"); }); - test("the retired beta-api freestyle snapshot is still not deployable", () => { - // That entry was baked against beta-api.freestyle.sh and keeps - // validationStatus "unknown"; pointing the env at it must stay red so - // nobody ships a default provider that cannot boot a machine. - const result = auditCloudVmProviderCoherence( - { - CMUX_VM_DEFAULT_PROVIDER: "freestyle", - FREESTYLE_SANDBOX_SNAPSHOT: "sh-fb3dcf7b47894114889b10186626af5b", - FREESTYLE_API_KEY: "x", - }, - realManifest, - ) as Coherence; - expect(result.selected?.provider).toBe("freestyle"); - expect(result.problems.join("\n")).toContain("validationStatus"); - }); - - test("an image value outside the manifest is a problem", () => { - const result = auditProviderReadiness( - "freestyle", - { FREESTYLE_SANDBOX_SNAPSHOT: "sh-not-a-real-snapshot", FREESTYLE_API_KEY: "x" }, - realManifest, - ) as { problems: string[] }; - expect(result.problems.join("\n")).toContain("not listed in the image manifest"); + test("a lingering FREESTYLE_SANDBOX_SNAPSHOT is stale configuration, whatever it names", () => { + // No env var selects an image any more: the manifest is the only source + // of truth. A leftover selector (even one naming a valid entry) would + // mislead whoever reads the deployment, so the audit asks for its removal. + for (const value of ["sh-940ec3bc46224c019e5e8d9a97053293", "sh-fb3dcf7b47894114889b10186626af5b", "sh-not-a-real-snapshot"]) { + const result = auditCloudVmProviderCoherence( + { CMUX_VM_DEFAULT_PROVIDER: "freestyle", FREESTYLE_SANDBOX_SNAPSHOT: value, FREESTYLE_API_KEY: "x" }, + realManifest, + ) as Coherence; + expect(result.selected?.provider).toBe("freestyle"); + expect(result.problems.join("\n")).toContain("FREESTYLE_SANDBOX_SNAPSHOT is set but ignored"); + } }); test("a provider with no manifest entries at all is a problem", () => { @@ -138,14 +168,6 @@ describe("cloud VM provider coherence audit", () => { expect(result.problems.join("\n")).toContain("no entries in the image manifest"); }); - test("a manifest entry that never passed validation is a problem", () => { - const result = auditProviderReadiness( - "freestyle", - { FREESTYLE_SANDBOX_SNAPSHOT: "sh-fb3dcf7b47894114889b10186626af5b", FREESTYLE_API_KEY: "x" }, - realManifest, - ) as { problems: string[] }; - expect(result.problems.join("\n")).toContain("validationStatus"); - }); }); describe("sensitive env placeholders", () => { @@ -166,7 +188,7 @@ describe("sensitive env placeholders", () => { }, realManifest, ) as Coherence; - expect(result.problems.join("\n")).toContain("cannot be audited"); + expect(result.problems.join("\n")).toContain("FREESTYLE_SANDBOX_SNAPSHOT is set but ignored"); }); }); @@ -208,7 +230,6 @@ describe("required runtime env keys cover the production provider path", () => { test("freestyle credentials, cron auth, and the alert sink are required", () => { for (const key of [ "FREESTYLE_API_KEY", - "FREESTYLE_SANDBOX_SNAPSHOT", "CMUX_VM_FREESTYLE_ENABLED", "CRON_SECRET", "CMUX_ALERTS_SLACK_WEBHOOK_URL", diff --git a/web/tests/vm-devbox-desktop.test.ts b/web/tests/vm-devbox-desktop.test.ts new file mode 100644 index 000000000000..55e66fc3c918 --- /dev/null +++ b/web/tests/vm-devbox-desktop.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { readdirSync, readFileSync } from "node:fs"; +import path from "node:path"; +import { + DEVBOX_DESKTOP_FILES, + DEVBOX_GHOSTTY_DEB_URL, + devboxDesktopDir, +} from "../scripts/devbox-image-common"; + +// Contract tests for the devbox desktop layer +// (services/vms/images/devbox/desktop), ported from the retired Blaxel +// cmux-devbox image and baked into the Freestyle snapshot by +// build-devbox-freestyle.ts. The Mac CLI opens the machine's screen on 6901 +// (cloudVMDesktopPort) and a provider heal runs start-vnc.sh as `cua`, so the +// ports, user, and file paths are pinned here. + +const read = (name: string) => readFileSync(path.join(devboxDesktopDir, name), "utf8"); +const startVnc = read("start-vnc.sh"); +const unit = read("cmux-desktop.service"); +const boot = read("cmux-desktop-boot"); +const freestyleBake = readFileSync(path.join(import.meta.dirname, "../scripts/build-devbox-freestyle.ts"), "utf8"); +const verify = readFileSync(path.join(import.meta.dirname, "../scripts/verify-devbox-image.ts"), "utf8"); + +describe("devbox desktop layer", () => { + test("directory contains exactly the expected files", () => { + expect(readdirSync(devboxDesktopDir).sort()).toEqual([...DEVBOX_DESKTOP_FILES].sort()); + }); + + test("every shell file parses", () => { + expect(spawnSync("bash", ["-n", path.join(devboxDesktopDir, "start-vnc.sh")]).status).toBe(0); + expect(spawnSync("sh", ["-n", path.join(devboxDesktopDir, "cmux-desktop-boot")]).status).toBe(0); + }); + + test("keeps the desktop port contract: RFB 5901 loopback-only, noVNC on 6901", () => { + expect(startVnc).toContain("-rfbport 5901"); + expect(startVnc).toContain("-SecurityTypes None"); + expect(startVnc).toContain("-localhost"); + // The CLI's cloudVMDesktopPort: the noVNC web client must answer on 6901. + expect(startVnc).toContain("websockify --web /usr/share/novnc --heartbeat 30 0.0.0.0:6901 127.0.0.1:5901"); + expect(freestyleBake).toContain("ln -s vnc.html /usr/share/novnc/index.html"); + // The verifier proves both ports from inside the VM (hex 170D / 1AF5). + expect(verify).toContain("/:170D$/"); + expect(verify).toContain("/:1AF5$/"); + }); + + test("runs as the ubuntu work user under systemd, supervised", () => { + // One account for everything: SSH, the API's default exec user, terminal + // panes, coding agents, and the desktop session all land in /home/ubuntu. + expect(unit).toContain("User=ubuntu"); + expect(unit).toContain("Environment=HOME=/home/ubuntu"); + expect(unit).toContain("Environment=DISPLAY=:1"); + expect(unit).toContain("ExecStart=/usr/local/bin/cmux-desktop-boot"); + expect(unit).toContain("Restart=always"); + expect(boot).toContain("bash /usr/local/bin/start-vnc.sh"); + // Chrome's first run is pre-accepted for the desktop user on every boot. + expect(boot).toContain('touch "$HOME/.config/google-chrome/First Run"'); + expect(freestyleBake).toContain("systemctl enable --now cmux-desktop"); + expect(freestyleBake).toContain('"desktop/cmux-desktop.service", "/etc/systemd/system/cmux-desktop.service"'); + expect(freestyleBake).toContain("grep -q '^User=${WORK_USER}$' /etc/systemd/system/cmux-desktop.service"); + expect(verify).toContain("pgrep -u ubuntu -x 'Xvnc|Xtigervnc'"); + expect(verify).toContain("/home/ubuntu/.config/google-chrome/First Run"); + }); + + test("the work user is the base's ubuntu account, never a user the bake creates", () => { + // Coding agents refuse a root shell (`claude --dangerously-skip-permissions`); + // freestyle/ubuntu already ships `ubuntu` at uid 1000 with NOPASSWD sudo. + expect(freestyleBake).toContain('const WORK_USER = "ubuntu"'); + expect(freestyleBake).not.toContain("useradd"); + expect(freestyleBake).toContain(`[ "$(id -u \${WORK_USER})" = 1000 ] && sudo -n -u \${WORK_USER} sudo -n true`); + expect(verify).toContain("id -u ubuntu"); + expect(verify).toContain("= 1000 ] && sudo -n -u ubuntu sudo -n true"); + }); + + test("Ghostty comes from a pinned Ubuntu 24.04 release asset, never a moving tag", () => { + expect(DEVBOX_GHOSTTY_DEB_URL).toMatch( + /ghostty-ubuntu\/releases\/download\/[0-9][^\s]*\/ghostty_[0-9][^\s]*_amd64_24\.04\.deb$/, + ); + expect(freestyleBake).toContain("DEVBOX_GHOSTTY_DEB_URL"); + expect(freestyleBake).toContain("ghostty +version"); + }); + + test("desktop polish: pre-accepted Chrome, CC0 wallpaper, no clock, dock order", () => { + expect(read("google-chrome-cmux.desktop")).toContain("--no-first-run"); + for (const app of ["google-chrome-cmux.desktop", "thunar-cmux.desktop", "ghostty-cmux.desktop"]) { + expect(read(app)).toMatch(/^Icon=\/etc\/cmux\/icons\/[a-z-]+\.png$/m); + expect(freestyleBake).toContain(`"desktop/${app}", "/etc/cmux/apps/${app}"`); + } + for (const icon of ["google-chrome.png", "thunar.png", "ghostty.png"]) { + expect(freestyleBake).toContain(`/etc/cmux/icons/${icon}`); + } + expect(read("WALLPAPER.md")).toContain("CC0 1.0 Universal"); + expect(freestyleBake).toContain('"desktop/wallpaper.jpg", "/usr/share/backgrounds/cmux/wallpaper.jpg"'); + expect(startVnc).toContain("feh --no-fehbg --bg-fill /usr/share/backgrounds/cmux/wallpaper.jpg"); + const tint2 = read("tint2rc"); + expect(tint2).toContain("panel_items = LT"); + expect(tint2).not.toContain("time1_format"); + expect(tint2.split("\n").filter((line) => line.startsWith("launcher_item_app")).join("\n")).toBe( + [ + "launcher_item_app = /etc/cmux/apps/google-chrome-cmux.desktop", + "launcher_item_app = /etc/cmux/apps/thunar-cmux.desktop", + "launcher_item_app = /etc/cmux/apps/ghostty-cmux.desktop", + ].join("\n"), + ); + }); + + test("tint2rc defines every background before the line that references it", () => { + // tint2 resolves `*_background_id = N` while parsing, against the backgrounds + // defined ABOVE that line (id 0 is the built-in transparent one; each + // `rounded =` opens the next). A forward reference clamps to -1 and hands the + // panel a garbage Background: the launcher's icon size goes negative and the + // whole dock paints nothing (the 2026-08-27 invisible-toolbar regression). + const lines = read("tint2rc").split("\n"); + let backgrounds = 1; + for (const [index, raw] of lines.entries()) { + const line = raw.trim(); + if (line.startsWith("rounded")) backgrounds += 1; + const ref = /^([a-z_]+_background_id)\s*=\s*(\d+)/.exec(line); + if (!ref) continue; + if (Number(ref[2]) >= backgrounds) { + throw new Error( + `tint2rc line ${index + 1}: ${ref[1]} = ${ref[2]} references a background that is not defined yet (${backgrounds} known so far)`, + ); + } + } + expect(backgrounds).toBeGreaterThan(1); + }); + + test("the verifier pins every desktop file byte-identical and the bake stamps the layer", () => { + for (const name of DEVBOX_DESKTOP_FILES) { + if (name === "WALLPAPER.md") continue; + expect({ name, pinned: verify.includes(`["${name}", `) }).toEqual({ name, pinned: true }); + } + expect(freestyleBake).toContain('withDesktop ? " desktop" : ""'); + expect(verify).toContain("/\\bdesktop\\b/.test(stamp.output)"); + }); +}); diff --git a/web/tests/vm-devbox-image.test.ts b/web/tests/vm-devbox-image.test.ts index 102914bdf2a8..8630f8c1be87 100644 --- a/web/tests/vm-devbox-image.test.ts +++ b/web/tests/vm-devbox-image.test.ts @@ -10,7 +10,7 @@ import { CMUX_TUI_SESSION, cmuxTuiDaemonCommand, } from "../services/vms/drivers/cmuxTuiDaemon"; -import { DEVBOX_TEMPLATE_FILES, devboxAgentPins } from "../scripts/devbox-image-common"; +import { DEVBOX_TEMPLATE_FILES, devboxAgentPins, devboxCuaDriverVersion } 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, @@ -76,6 +76,9 @@ describe("devbox image template", () => { "chrome-managed-policy.json", "cmux-bashrc", "cmux-devbox-boot", + "cmux-motd", + // The desktop layer (Freestyle only); pinned by vm-devbox-desktop.test.ts. + "desktop", "seed-history", ]); // The bake scripts' preflight covers the same set (minus the README). @@ -85,6 +88,7 @@ describe("devbox image template", () => { "chrome-managed-policy.json", "cmux-bashrc", "cmux-devbox-boot", + "cmux-motd", "seed-history", ]); }); @@ -94,8 +98,83 @@ describe("devbox image template", () => { const result = spawnSync("bash", ["-n", path.join(templateDir, name)]); expect({ name, status: result.status }).toEqual({ name, status: 0 }); } - const result = spawnSync("sh", ["-n", path.join(templateDir, "cmux-devbox-boot")]); - expect(result.status).toBe(0); + for (const name of ["cmux-devbox-boot", "cmux-motd"]) { + const result = spawnSync("sh", ["-n", path.join(templateDir, name)]); + expect({ name, status: result.status }).toEqual({ name, status: 0 }); + } + }); + + test("the login banner is cmux's, offline, and installed everywhere the base motd was", () => { + const motd = read("cmux-motd"); + // The `cmux cloud` chevron logo from the CLI's cloud welcome + // (CLI/cmux.swift), same gradient and tagline. + expect(motd).toContain("persistent cloud VM"); + expect(motd).toContain("ready for coding agents"); + for (const rgb of ["0;212;255", "24;181;250", "48;150;245", "72;119;241", "96;88;239", "110;73;238", "124;58;237"]) { + expect(motd).toContain(`38;2;${rgb}m`); + } + expect(readFileSync(path.join(import.meta.dirname, "../../CLI/cmux.swift"), "utf8")).toContain( + "x cloud\\\\033[0m", + ); + // Seeds are readable by the work user (the seed pass runs as root and + // ble.sh creates its cache dir 0700), and Ghostty's TERM is seeded too. + expect(dockerfile).toContain("chmod -R a+rX /etc/cmux/blesh-cache-seed"); + expect(readScript("build-devbox-freestyle.ts")).toContain("chmod -R a+rX /etc/cmux/blesh-cache-seed"); + expect(readScript("build-devbox-freestyle.ts")).toContain("linux xterm-ghostty; do"); + // Fast and offline: only baked files and cheap local commands. + for (const forbidden of ["curl", "wget", "npm ", "claude --version", "apt"]) { + expect({ forbidden, present: motd.includes(forbidden) }).toEqual({ forbidden, present: false }); + } + // Same sections as `cmux welcome`: shortcuts and the links. + expect(motd).toContain("Shortcuts"); + for (const line of ["New workspace", "Command palette", "Jump to latest unread", "https://cmux.com/docs", "https://discord.gg/xsgFEVrWCZ", "founders@manaflow.com"]) { + expect(motd).toContain(line); + } + expect(dockerfile).toContain("COPY cmux-motd /etc/update-motd.d/00-cmux"); + const freestyleScript = readScript("build-devbox-freestyle.ts"); + expect(freestyleScript).toContain('"cmux-motd", "/etc/update-motd.d/00-cmux", 0o755'); + // The stock Ubuntu scripts stay in place but silent; the static motd is emptied. + expect(freestyleScript).toContain("chmod -x"); + expect(freestyleScript).toContain(": > /etc/motd"); + }); + + test("the Freestyle bake uses the base's toolchain and pins the agents on top of it", () => { + // freestyle/ubuntu ships Node LTS under nvm (symlinked into /usr/local/bin), + // Bun, Python 3.12, uv and Docker, plus its own copies of Claude Code, + // Codex and OpenCode. The bake keeps that toolchain (no mise) and replaces + // the agent copies with the exact Dockerfile pins via the base's npm, then + // symlinks every agent bin into /usr/local/bin so non-login shells (daemon + // panes) resolve them without a profile. + const freestyleScript = readScript("build-devbox-freestyle.ts"); + expect(freestyleScript).not.toContain("mise.run"); + expect(freestyleScript).not.toContain("/opt/mise"); + expect(freestyleScript).toContain("readlink /usr/local/bin/node | grep -q /usr/local/nvm/"); + expect(freestyleScript).toContain("npm install -g --foreground-scripts"); + expect(freestyleScript).toContain('nvm_bin="$(dirname "$(readlink -f /usr/local/bin/node)")"'); + expect(freestyleScript).toContain('ln -sfn "$nvm_bin/${pin.binary}" /usr/local/bin/${pin.binary}'); + // The pins are proven from a clean login shell AS the work user during the + // bake itself (probing as root with HOME=/home/ubuntu leaves root-owned + // state dirs that break ble.sh for every later login). + expect(freestyleScript).toContain("sudo -n -u ${WORK_USER} env -i HOME=${WORK_HOME} USER=${WORK_USER} TERM=xterm bash -lc '${pin.binary} --version' | grep -F '${pin.version}'"); + // Home hygiene: single devshell source, ble.sh state dir, legal notice + // silenced, home owned by the work user, two silent real logins. + // Per-user rc files only: after Ubuntu's own PS1, and loaded once. + expect(freestyleScript).toContain('const rcFiles = ["/etc/skel/.bashrc", "/root/.bashrc", `${WORK_HOME}/.bashrc`]'); + expect(freestyleScript).toContain("chmod a+rwxt /usr/local/share/blesh/state.d"); + expect(freestyleScript).toContain("motd.legal-displayed"); + expect(freestyleScript).toContain("chown -R ${WORK_USER}:${WORK_USER} ${WORK_HOME}"); + // The interactive probe is a real pty (tmux) as the work user and requires the cmux prompt. + expect(freestyleScript).toContain("interactiveShellProbe(1)"); + expect(freestyleScript).toContain("interactiveShellProbe(2)"); + expect(freestyleScript).toContain('grep -q "λ"'); + expect(readScript("verify-devbox-image.ts")).toContain("ubuntu-login-silent-"); + expect(readScript("verify-devbox-image.ts")).toContain("home-owned-by-ubuntu"); + expect(readScript("verify-devbox-image.ts")).toContain("devshell-sourced-once"); + // The verifier checks both shell families without PATH help of its own. + const verify = readScript("verify-devbox-image.ts"); + expect(verify).toContain("-login-pin-ok"); + expect(verify).toContain("-nonlogin-pin-ok"); + expect(verify).toContain("test ! -e /opt/mise"); }); test("ble.sh integration stays minimal: no token highlighting, ghost text only", () => { @@ -215,9 +294,10 @@ describe("devbox image template", () => { const devboxCuaVersion = /CUA_DRIVER_RS_VERSION=(\S+)/.exec(dockerfile)?.[1]; expect(devboxCuaVersion).toBeTruthy(); - expect(readScript("build-devbox-freestyle.ts")).toContain( - `CUA_DRIVER_RS_VERSION=${devboxCuaVersion}`, - ); + expect(devboxCuaDriverVersion(dockerfile)).toBe(devboxCuaVersion!); + // The Freestyle replay reads the pin through the helper, never a second copy. + expect(readScript("build-devbox-freestyle.ts")).toContain("CUA_DRIVER_RS_VERSION=${cuaVersion}"); + expect(readScript("build-devbox-freestyle.ts")).toContain("devboxCuaDriverVersion()"); }); test("one public-platform SDK serves the bake, the verifier, and the driver", () => { diff --git a/web/tests/vm-image-manifest.test.ts b/web/tests/vm-image-manifest.test.ts new file mode 100644 index 000000000000..bc7167bb4ec6 --- /dev/null +++ b/web/tests/vm-image-manifest.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test } from "bun:test"; +import { + imageManifestProblems, + promoteImageManifestEntry, + readImageManifest, + type DevboxImageManifest, + type DevboxManifestEntry, +} from "../scripts/devbox-image-common"; + +// The checked-in image manifest (services/vms/images/manifest.json) is the +// source of truth for the image Cloud VM users get: the resolver serves the +// entry flagged defaultForKind when no env selector is set, also in deployed +// runtimes. These tests pin the invariants that make that safe, and the +// promote step (promote-devbox-image.ts) that is the only sanctioned writer. + +const passedEntry = (overrides: Partial = {}): DevboxManifestEntry => ({ + provider: "freestyle", + version: "freestyle-cmux-devbox-test", + imageId: "sh-0000000000000000000000000000test", + envVar: "FREESTYLE_SANDBOX_SNAPSHOT", + defaultForLocalDev: false, + cmuxdRemoteCommit: "none-cmux-tui", + repoCommit: "abc123", + builtAt: "2026-09-02T00:00:00.000Z", + builderScriptVersion: "deadbeef", + agentToolResolvedVersions: { "@anthropic-ai/claude-code": "2.1.252" }, + validationStatus: "passed", + notes: "cmux devbox epoch test", + ...overrides, +}); + +describe("checked-in image manifest", () => { + test("holds its invariants", () => { + expect(imageManifestProblems(readImageManifest())).toEqual([]); + }); + + test("every defaultForKind entry is a validated image", () => { + for (const entry of readImageManifest().images) { + if (entry.defaultForKind) { + expect({ version: entry.version, validationStatus: entry.validationStatus }) + .toEqual({ version: entry.version, validationStatus: "passed" }); + } + } + }); +}); + +describe("promoteImageManifestEntry", () => { + const base: DevboxImageManifest = { + schemaVersion: 1, + images: [ + passedEntry({ + version: "freestyle-old-desktop", + imageId: "sh-old", + kind: "desktop", + defaultForKind: true, + defaultForLocalDev: true, + }), + passedEntry({ version: "freestyle-old-base", imageId: "sh-old", kind: "base", defaultForKind: true }), + passedEntry({ provider: "e2b", version: "e2b-x", imageId: "cmux-devbox:x", envVar: "E2B_CMUXD_WS_TEMPLATE", kind: "base", defaultForKind: true }), + ], + }; + + test("appends one entry per kind, flags them default, and demotes the provider's old defaults", () => { + const next = promoteImageManifestEntry(base, passedEntry(), { + kinds: ["desktop", "base"], + validationNotes: "Validated in test.", + }); + // Pure: the input is untouched. + expect(base.images[0].defaultForKind).toBe(true); + expect(imageManifestProblems(next)).toEqual([]); + expect(next.images).toHaveLength(5); + expect(next.images.slice(0, 3).map((e) => [e.version, e.defaultForKind])).toEqual([ + ["freestyle-old-desktop", false], + ["freestyle-old-base", false], + // Another provider's defaults are not this promotion's business. + ["e2b-x", true], + ]); + expect(next.images.slice(3)).toMatchObject([ + { version: "freestyle-cmux-devbox-test", kind: "desktop", defaultForKind: true }, + { version: "freestyle-cmux-devbox-test-base", kind: "base", defaultForKind: true }, + ]); + expect(next.images[3].notes).toBe("cmux devbox epoch test Validated in test."); + }); + + test("promoting one kind leaves the other kind's default alone", () => { + const next = promoteImageManifestEntry(base, passedEntry(), { kinds: ["base"] }); + expect(next.images.map((e) => [e.version, e.kind, e.defaultForKind])).toEqual([ + ["freestyle-old-desktop", "desktop", true], + ["freestyle-old-base", "base", false], + ["e2b-x", "base", true], + ["freestyle-cmux-devbox-test", "base", true], + ]); + }); + + test("refuses anything the verifier has not passed", () => { + for (const status of ["unknown", "failed"] as const) { + expect(() => + promoteImageManifestEntry(base, passedEntry({ validationStatus: status }), { kinds: ["base"] }), + ).toThrow(/validationStatus is (unknown|failed), not passed/); + } + }); + + test("refuses to list the same image twice for a kind, and refuses no kinds", () => { + expect(() => + promoteImageManifestEntry(base, passedEntry({ imageId: "sh-old" }), { kinds: ["desktop"] }), + ).toThrow(/already listed as freestyle-old-desktop \(desktop\)/); + expect(() => promoteImageManifestEntry(base, passedEntry(), { kinds: [] })).toThrow(/no kinds/); + }); +}); + +describe("imageManifestProblems", () => { + test("flags two defaults for one provider+kind and an unvalidated default", () => { + const bad: DevboxImageManifest = { + schemaVersion: 1, + images: [ + passedEntry({ version: "a", imageId: "sh-a", kind: "base", defaultForKind: true }), + passedEntry({ version: "b", imageId: "sh-b", kind: "base", defaultForKind: true, validationStatus: "unknown" }), + passedEntry({ version: "c", imageId: "sh-c", defaultForLocalDev: true }), + passedEntry({ version: "d", imageId: "sh-d", defaultForLocalDev: true }), + passedEntry({ version: "d", imageId: "sh-e" }), + ], + }; + const problems = imageManifestProblems(bad); + expect(problems).toEqual(expect.arrayContaining([ + expect.stringContaining("b: defaultForKind but validationStatus is unknown"), + expect.stringContaining("freestyle/base: 2 entries flagged defaultForKind"), + expect.stringContaining("freestyle/d: version listed more than once"), + ])); + }); +}); diff --git a/web/tests/vm-image-resolver.test.ts b/web/tests/vm-image-resolver.test.ts index 24d5ed746b19..2cbf7c2faa19 100644 --- a/web/tests/vm-image-resolver.test.ts +++ b/web/tests/vm-image-resolver.test.ts @@ -3,7 +3,6 @@ import { reportVmImageConfigError, inferVmProviderForImage, listVmImageKinds, - providerImageEnvKey, resolveVmImage, vmImageKindFor, type VmImageKind, @@ -20,31 +19,33 @@ function captureImageConfigError(fn: () => unknown): VmImageConfigError { throw new Error("expected VmImageConfigError to be thrown"); } +// cmux's validated public-platform devbox (baked on cmux's Freestyle account): +// the manifest's base default. The manifest is the only source of truth for +// images; no env var selects or overrides one. const validatedSnapshot = "sh-940ec3bc46224c019e5e8d9a97053293"; const validatedVersion = "freestyle-cmux-devbox-20260902c"; +// The desktop devbox from this PR: validated, listed under both kinds with one +// image id, but baked on another Freestyle account, so not a default until +// re-promoted under cmux's key. +const desktopSnapshot = "sh-1d66b9cad53a4811b5f63a4cae0b3faf"; +const desktopVersion = "freestyle-cmux-devbox-20260902h"; describe("VM image resolver: request by kind", () => { const deployed = { VERCEL: "1", VERCEL_ENV: "production" }; - test("desktop images get their own env selector; single-variable providers share one", () => { - expect(providerImageEnvKey("freestyle")).toBe("FREESTYLE_SANDBOX_SNAPSHOT"); - expect(providerImageEnvKey("freestyle", "base")).toBe("FREESTYLE_SANDBOX_SNAPSHOT"); - // Freestyle ships no desktop image today, so it has no distinct - // `_IMAGE`-suffixed selector to split; the generic one serves both kinds. - expect(providerImageEnvKey("freestyle", "desktop")).toBe("FREESTYLE_SANDBOX_SNAPSHOT"); - }); - - test("the kind env var resolves a manifest image of that kind", () => { - expect( - resolveVmImage("freestyle", undefined, { - ...deployed, - FREESTYLE_SANDBOX_SNAPSHOT: validatedSnapshot, - }, { kind: "base" }), - ).toMatchObject({ - image: validatedSnapshot, - imageVersion: validatedVersion, - kind: "base", - }); + test("FREESTYLE_SANDBOX_SNAPSHOT is ignored: only the manifest decides", () => { + // The env var used to select (and could override) the image. It no longer + // exists as far as the resolver is concerned: a stale value naming another + // manifest entry, or nothing in the manifest at all, changes nothing. + for (const stale of ["sh-fb3dcf7b47894114889b10186626af5b", "sh-ops-override", ""]) { + expect( + resolveVmImage("freestyle", undefined, { ...deployed, FREESTYLE_SANDBOX_SNAPSHOT: stale }, { kind: "base" }), + ).toMatchObject({ image: validatedSnapshot, imageVersion: validatedVersion, kind: "base" }); + expect(resolveVmImage("freestyle", undefined, { FREESTYLE_SANDBOX_SNAPSHOT: stale })).toMatchObject({ + image: validatedSnapshot, + imageVersion: validatedVersion, + }); + } }); test("an explicitly requested image of the wrong kind still errors", () => { @@ -53,58 +54,56 @@ describe("VM image resolver: request by kind", () => { expect(err.reason).toMatch(/base image, not a desktop image/); }); - test("freestyle ships no desktop image, so every desktop request fails closed", () => { - // The desktop kind and its noVNC wrapper are kept as a seam for Freestyle - // desktop support; until an image exists, the request must 503 with a - // config error rather than silently serving a base machine. - for (const provider of ["freestyle"] as const) { - const err = captureImageConfigError(() => - resolveVmImage(provider, undefined, deployed, { kind: "desktop" }), - ); - expect(err).toMatchObject({ provider, kind: "desktop", source: "default" }); - } + test("no desktop default is recorded yet, so a desktop request fails closed", () => { + // The desktop devbox is listed (validated) but not a default: it was baked + // on another Freestyle account. Until it is re-promoted under cmux's key, + // a desktop request must 503 with a config error rather than silently + // serving a base machine. + const err = captureImageConfigError(() => + resolveVmImage("freestyle", undefined, deployed, { kind: "desktop" }), + ); + expect(err).toMatchObject({ provider: "freestyle", kind: "desktop", source: "default" }); }); - test("freestyle resolves only through its env selector, like every other provider", () => { - // The validated public-platform devbox entry is neither a kind default - // nor a local-dev default, so a deployment must set - // FREESTYLE_SANDBOX_SNAPSHOT explicitly; nothing is served silently. - const err = captureImageConfigError(() => resolveVmImage("freestyle", undefined, deployed)); - expect(err).toMatchObject({ + test("the committed manifest default serves base machines", () => { + // The manifest is the only source of truth: the entry flagged + // defaultForKind is what every runtime serves, deployed or local, and a + // request with neither image nor kind gets the base default too. + expect(resolveVmImage("freestyle", undefined, deployed, { kind: "base" })).toMatchObject({ provider: "freestyle", - envVar: "FREESTYLE_SANDBOX_SNAPSHOT", - source: "env", + image: validatedSnapshot, + imageVersion: validatedVersion, + kind: "base", }); - expect(captureImageConfigError(() => resolveVmImage("freestyle", undefined, {}))).toMatchObject({ - provider: "freestyle", - reason: "no local default image is recorded for freestyle", + expect(listVmImageKinds("freestyle", deployed)).toEqual([{ kind: "base", image: validatedSnapshot }]); + expect(resolveVmImage("freestyle", undefined, {})).toMatchObject({ + image: validatedSnapshot, + imageVersion: validatedVersion, }); - expect(listVmImageKinds("freestyle", deployed)).toEqual([]); - }); - - test("the validated public-platform devbox snapshot resolves from the env selector", () => { - const env = { ...deployed, FREESTYLE_SANDBOX_SNAPSHOT: validatedSnapshot }; - expect(resolveVmImage("freestyle", undefined, env)).toMatchObject({ - provider: "freestyle", + expect(resolveVmImage("freestyle", undefined, deployed)).toMatchObject({ image: validatedSnapshot, imageVersion: validatedVersion, + kind: "base", }); - expect(listVmImageKinds("freestyle", env)).toEqual([ - { kind: "base", image: validatedSnapshot }, - ]); }); - test("an operator-set freestyle snapshot still resolves, so a re-bake is env-only", () => { - expect( - resolveVmImage("freestyle", undefined, { - ...deployed, - FREESTYLE_SANDBOX_SNAPSHOT: "sh-fb3dcf7b47894114889b10186626af5b", - }), - ).toMatchObject({ - provider: "freestyle", - image: "sh-fb3dcf7b47894114889b10186626af5b", - imageVersion: "freestyle-cmux-devbox-beta1", + test("an image listed under two kinds resolves to the entry of the requested kind", () => { + // A client-requested image, or the env selector, naming the shared + // desktop snapshot must not be rejected as "a desktop image, not a base image". + expect(resolveVmImage("freestyle", desktopSnapshot, deployed, { kind: "base" })).toMatchObject({ + imageVersion: `${desktopVersion}-base`, + kind: "base", + }); + expect(resolveVmImage("freestyle", desktopSnapshot, deployed, { kind: "desktop" })).toMatchObject({ + imageVersion: desktopVersion, + kind: "desktop", }); + expect(resolveVmImage("freestyle", desktopVersion, deployed, { kind: "desktop" })).toMatchObject({ + image: desktopSnapshot, + kind: "desktop", + }); + // Without a kind the first listing wins, and a stored image id reads as desktop. + expect(vmImageKindFor("freestyle", desktopSnapshot)).toBe("desktop"); }); test("rejects unknown kinds with an actionable error", () => { @@ -114,39 +113,35 @@ describe("VM image resolver: request by kind", () => { expect(err).toMatchObject({ provider: "freestyle", kind: "gpu", source: "request" }); expect(reportVmImageConfigError(err, deployed)).toMatchObject({ message: 'Cloud VM image kind "gpu" is not supported.', - // Nothing is flagged defaultForKind and `deployed` sets no selector, so - // no kind is currently servable. - details: { imageRequested: false, kind: "gpu", source: "request", allowedKinds: [] }, + // The manifest's base default is servable even with no selector set. + details: { imageRequested: false, kind: "gpu", source: "request", allowedKinds: ["base"] }, }); }); - test("a kind with nothing configured names the env var and stays client-safe", () => { + test("a kind with no manifest default fails closed and stays client-safe", () => { const err = captureImageConfigError(() => resolveVmImage("freestyle", undefined, deployed, { kind: "desktop" }), ); expect(err).toMatchObject({ provider: "freestyle", - envVar: "FREESTYLE_SANDBOX_SNAPSHOT", kind: "desktop", source: "default", - reason: "no desktop image is configured for freestyle: set FREESTYLE_SANDBOX_SNAPSHOT or record a desktop manifest default", + reason: "no desktop image is recorded as the manifest default for freestyle: promote one (bun run devbox:promote -- freestyle)", }); const report = reportVmImageConfigError(err, deployed); expect(report.message).toBe("No desktop Cloud VM image is available in this environment."); - expect(report.action).toContain("available: none"); - // Client-safe details name the kind and the source, never the env var or image ids. + expect(report.action).toContain("available: base"); + // Client-safe details name the kind and the source, never image ids or manifest wording. expect(report.details).toEqual({ imageRequested: false, kind: "desktop", source: "default", - allowedKinds: [], + allowedKinds: ["base"], }); - expect(JSON.stringify(report.details)).not.toMatch(/FREESTYLE_|manifest\.json|sh-[a-z0-9]/); + expect(JSON.stringify(report.details)).not.toMatch(/FREESTYLE_|manifest|sh-[a-z0-9]/); // The operator log carries what the response may not. - expect(report.operator).toMatchObject({ - provider: "freestyle", - envVar: "FREESTYLE_SANDBOX_SNAPSHOT", - }); + expect(report.operator).toMatchObject({ provider: "freestyle", kind: "desktop" }); + expect(report.operator.allowedImages).toContain(validatedSnapshot); }); test("client-requested unknown images stay strict and report imageRequested", () => { @@ -155,7 +150,7 @@ describe("VM image resolver: request by kind", () => { ); expect(err).toMatchObject({ image: "cmuxd-ws:unlisted", source: "request" }); const report = reportVmImageConfigError(err, deployed); - expect(report.details).toEqual({ imageRequested: true, kind: undefined, source: "request", allowedKinds: [] }); + expect(report.details).toEqual({ imageRequested: true, kind: undefined, source: "request", allowedKinds: ["base"] }); expect(report.message).toBe("The requested Cloud VM image is not available in this environment."); expect(report.operator).toMatchObject({ image: "cmuxd-ws:unlisted" }); }); @@ -164,54 +159,20 @@ describe("VM image resolver: request by kind", () => { // No manifest kind and no `xfce`/`devbox` in the id: the heuristic says base. expect(vmImageKindFor("freestyle", validatedSnapshot)).toBe("base"); - // Nothing is flagged defaultForKind, so a kind only resolves from an env selector. - expect(listVmImageKinds("freestyle", deployed)).toEqual([]); - expect(listVmImageKinds("freestyle", { ...deployed, FREESTYLE_SANDBOX_SNAPSHOT: validatedSnapshot })).toEqual([ - { kind: "base", image: validatedSnapshot }, - ]); + // The base default is flagged in the manifest; the retired beta entry never is. + expect(listVmImageKinds("freestyle", deployed)).toEqual([{ kind: "base", image: validatedSnapshot }]); + expect(listVmImageKinds("freestyle", deployed).map((entry) => entry.image)).not.toContain("sh-fb3dcf7b47894114889b10186626af5b"); }); }); describe("VM image resolver", () => { - test("freestyle has no local default until an operator selects one", () => { - // The validated entry remains defaultForLocalDev:false, so local dev fails - // closed rather than silently booting a paid provider image. - expect(() => resolveVmImage("freestyle", undefined, {})).toThrow(VmImageConfigError); - expect(captureImageConfigError(() => resolveVmImage("freestyle", undefined, {}))).toMatchObject({ + test("local dev uses the manifest default", () => { + // `bun dev` boots the same validated image production does, with no env + // var to copy around. + expect(resolveVmImage("freestyle", undefined, {})).toMatchObject({ provider: "freestyle", - envVar: "FREESTYLE_SANDBOX_SNAPSHOT", - reason: "no local default image is recorded for freestyle", - }); - }); - - test("local dev resolves FREESTYLE_SANDBOX_SNAPSHOT even when unmanifested", () => { - expect( - resolveVmImage("freestyle", undefined, { - FREESTYLE_SANDBOX_SNAPSHOT: "sh-scratch", - }), - ).toMatchObject({ - provider: "freestyle", - image: "sh-scratch", - imageVersion: null, - manifestEntry: null, - }); - }); - - test("requires deployed env selectors", () => { - expect(() => - resolveVmImage("freestyle", undefined, { - VERCEL: "1", - VERCEL_ENV: "preview", - }), - ).toThrow(VmImageConfigError); - expect(captureImageConfigError(() => - resolveVmImage("freestyle", undefined, { - VERCEL: "1", - VERCEL_ENV: "preview", - }), - )).toMatchObject({ - provider: "freestyle", - reason: "FREESTYLE_SANDBOX_SNAPSHOT is required in deployed environments", + image: validatedSnapshot, + imageVersion: validatedVersion, }); }); @@ -224,40 +185,6 @@ describe("VM image resolver", () => { ).toThrow(VmImageConfigError); }); - test("resolves deployed env selectors through the manifest", () => { - expect( - resolveVmImage("freestyle", undefined, { - VERCEL: "1", - VERCEL_ENV: "production", - FREESTYLE_SANDBOX_SNAPSHOT: validatedSnapshot, - }), - ).toMatchObject({ - provider: "freestyle", - image: validatedSnapshot, - imageVersion: validatedVersion, - }); - }); - - test("accepts an env-configured image that is missing from the manifest", () => { - // Production drifted once: the provider's image selector named an image the - // manifest did not list, and every base open failed with imageRequested: - // true even though the client sent no image. Operator config wins; only - // client requests are strict. - expect( - resolveVmImage("freestyle", undefined, { - VERCEL: "1", - VERCEL_ENV: "production", - FREESTYLE_SANDBOX_SNAPSHOT: "sh-ops-override", - }), - ).toMatchObject({ - provider: "freestyle", - image: "sh-ops-override", - imageVersion: null, - manifestEntry: null, - kind: "base", - }); - }); - test("permits unmanifested images only when explicitly allowed", () => { expect( resolveVmImage("freestyle", "scratch-image", { diff --git a/web/tests/vm-route-auth.test.ts b/web/tests/vm-route-auth.test.ts index f8dff81f3fab..0db52fa1a2d9 100644 --- a/web/tests/vm-route-auth.test.ts +++ b/web/tests/vm-route-auth.test.ts @@ -1,4 +1,10 @@ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test"; +import manifestJson from "../services/vms/images/manifest.json"; + +// The manifest is the only source of truth for images: the base default is what +// a create with no image (and no kind) resolves to, in every environment. +const MANIFEST_BASE_DEFAULT = (manifestJson.images as Array<{ imageId: string; version: string; kind?: string; defaultForKind?: boolean }>) + .find((entry) => (entry.kind ?? "base") === "base" && entry.defaultForKind)!; const getUser = mock(async () => null); const runVmWorkflow = mock(async () => { @@ -23,7 +29,6 @@ const VM_ENV_KEYS = [ "CMUX_VM_FREESTYLE_ENABLED", "CMUX_VM_ALLOWED_ORIGINS", "CMUX_VM_ALLOW_UNMANIFESTED_IMAGES", - "FREESTYLE_SANDBOX_SNAPSHOT", "CMUX_VM_FREE_MAX_ACTIVE_VMS", "CMUX_VM_PLAN_PRO_MAX_ACTIVE_VMS", "CMUX_VM_FREE_MAX_MEMORY_MB", @@ -448,15 +453,14 @@ describe("VM REST auth", () => { }); test("creates by kind without an image and echoes the resolved kind", async () => { - // The deployed shape: the operator's snapshot selector names the image, and - // the client only asks for a kind. - process.env.FREESTYLE_SANDBOX_SNAPSHOT = "sh-fb3dcf7b47894114889b10186626af5b"; + // The deployed shape: the manifest's defaultForKind entry names the image, + // and the client only asks for a kind. getUser.mockResolvedValue(authedStackUser()); runVmWorkflow.mockResolvedValue({ providerVmId: "provider-vm-kind", provider: "freestyle", - image: "sh-fb3dcf7b47894114889b10186626af5b", - imageVersion: "freestyle-cmux-devbox-beta1", + image: MANIFEST_BASE_DEFAULT.imageId, + imageVersion: MANIFEST_BASE_DEFAULT.version, createdAt: 1_777_000_000_000, }); @@ -472,13 +476,12 @@ describe("VM REST auth", () => { expect(await response.json()).toMatchObject({ id: "provider-vm-kind", kind: "base" }); expect(createVm).toHaveBeenCalledWith(expect.objectContaining({ provider: "freestyle", - image: "sh-fb3dcf7b47894114889b10186626af5b", - imageVersion: "freestyle-cmux-devbox-beta1", + image: MANIFEST_BASE_DEFAULT.imageId, + imageVersion: MANIFEST_BASE_DEFAULT.version, })); }); test("a kind the provider cannot serve fails with an actionable image config error", async () => { - process.env.FREESTYLE_SANDBOX_SNAPSHOT = "sh-fb3dcf7b47894114889b10186626af5b"; getUser.mockResolvedValue(authedStackUser()); const create = await POST( @@ -518,7 +521,6 @@ describe("VM REST auth", () => { }); test("lists the kinds the default provider can serve alongside plan limits", async () => { - process.env.FREESTYLE_SANDBOX_SNAPSHOT = "sh-fb3dcf7b47894114889b10186626af5b"; getUser.mockResolvedValue(authedStackUser()); runVmWorkflow.mockResolvedValue([ { providerVmId: "devbox", provider: "freestyle", image: "sh-unlisted-shell", imageVersion: "v", status: "running", createdAt: 1_777_000_000_000 }, @@ -2280,6 +2282,8 @@ describe("VM REST auth", () => { }); test("omits image from image config errors when no image was resolved", async () => { + // No desktop default is recorded in the manifest, so a desktop request is + // the shape where nothing resolves; the error must not name an image. process.env.VERCEL = "1"; process.env.VERCEL_ENV = "preview"; getUser.mockResolvedValue(authedStackUser()); @@ -2288,7 +2292,7 @@ describe("VM REST auth", () => { new Request("https://cmux.test/api/vm", { method: "POST", headers: { origin: "https://cmux.test" }, - body: JSON.stringify({ provider: "freestyle" }), + body: JSON.stringify({ provider: "freestyle", kind: "desktop" }), }), ); @@ -2301,7 +2305,7 @@ describe("VM REST auth", () => { }, }); expectNoCloudVmImplementationLeaks(payload); - expect(payload.action).toContain("default Cloud VM image"); + expect(payload.action).toContain("Cloud VM image"); expect(payload).not.toHaveProperty("image"); expect(runVmWorkflow).not.toHaveBeenCalled(); }); @@ -2309,13 +2313,12 @@ describe("VM REST auth", () => { test("records manifest image version on create workflow input", async () => { process.env.VERCEL = "1"; process.env.VERCEL_ENV = "preview"; - process.env.FREESTYLE_SANDBOX_SNAPSHOT = "sh-fb3dcf7b47894114889b10186626af5b"; getUser.mockResolvedValue(authedStackUser()); runVmWorkflow.mockResolvedValue({ providerVmId: "provider-vm-manifest", provider: "freestyle", - image: "sh-fb3dcf7b47894114889b10186626af5b", - imageVersion: "freestyle-cmux-devbox-beta1", + image: MANIFEST_BASE_DEFAULT.imageId, + imageVersion: MANIFEST_BASE_DEFAULT.version, createdAt: 1_777_000_000_000, }); @@ -2329,8 +2332,8 @@ describe("VM REST auth", () => { expect(response.status).toBe(200); expect(createVm).toHaveBeenCalledWith(expect.objectContaining({ - image: "sh-fb3dcf7b47894114889b10186626af5b", - imageVersion: "freestyle-cmux-devbox-beta1", + image: MANIFEST_BASE_DEFAULT.imageId, + imageVersion: MANIFEST_BASE_DEFAULT.version, })); }); @@ -2342,8 +2345,8 @@ describe("VM REST auth", () => { runVmWorkflow.mockResolvedValue({ providerVmId: "provider-vm-freestyle-base", provider: "freestyle", - image: "sh-fb3dcf7b47894114889b10186626af5b", - imageVersion: "freestyle-cmux-devbox-beta1", + image: MANIFEST_BASE_DEFAULT.imageId, + imageVersion: MANIFEST_BASE_DEFAULT.version, createdAt: 1_777_000_000_000, });