diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index 22b9fda9a6..08592dec5c 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -186,7 +186,7 @@ from the squash message first, or use a plain merge.
| `packages/app/src/i18n/{en,es}.ts`, `packages/app/src/pages/layout/deep-links.ts` | App-self-reference "OpenCode" → "CoDev Code" across the shared web UI's i18n dicts (28 strings per locale): settings row descriptions, the update toasts, the WSL onboarding step/status strings, the `dialog.server.description`, `error.chain.mcpFailed`, `sidebar.gettingStarted.line1`, the provider-connect "use {{provider}} models in OpenCode" lines, and `app.name.desktop`. The Zen free-models dialog title (`dialog.model.unpaid.freeModels.title`, "Free models provided by OpenCode") was **neutralized** to "Free models included" / "Modelos gratuitos incluidos" rather than rebranded or kept — it read oddly branded either way. i18n **keys** are untouched (they carry `Opencode`/lowercase `opencode`, which never match the exact-case `OpenCode` in values). The deep-link bridge window type `OpenCodeWindow` → `CodevWindow` (its `__CODEV__` global was already renamed in the desktop row). | These render in the desktop app (and the web console), which the fork ships; closes the "in-app i18n strings" gap for this package. | Keep our strings; take upstream's surrounding changes. PRESERVED deliberately: the real external-service names **"OpenCode Zen"** / **"OpenCode Go"** (in the `opencodeZen.*` provider-connect keys and the "OpenCode Go" upsell), plus every `opencode.ai/*` URL — the fork routes to those actual services, so the display text must match what users land on. The built-in **"OpenCode" theme** name (`packages/ui/src/theme/context.tsx`, the shiki theme id, `desktop-theme.schema.json`) is also kept — it names a specific palette, and the fork ships its own `codev` theme alongside it. |
| Browser-opened docs-site links: `packages/tui/src/app.tsx`, `packages/app/src/desktop-menu.ts`, `packages/app/src/components/{settings-general,settings-v2/general,dialog-custom-provider}.tsx`, `packages/app/src/pages/{home,layout,error}.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/opencode/src/cli/cmd/{providers,github.handler}.ts` | Links the app opens in the user's browser repointed from `opencode.ai` to the fork's hosted docs. Each one derives its base from `DOCS_URL` (`packages/codev-brand/src/index.ts`, base64-encoded) rather than hardcoding the host: the site is locale-routed and the Code docs live under `/code`, and no URL takes a trailing slash (the site 308-redirects `…/themes/` to `…/themes`). Sites: TUI "Open docs" command and app Documentation menu item → docs root; settings theme "Learn more" (v1 + v2) → `…/code/themes`; custom-provider dialog link → `…/code/providers#custom-provider`; CLI Cloudflare-gateway hint → `…/code/providers#cloudflare-ai-gateway`; GitHub-agent hint → `…/code/github`; home/layout sidebar Help buttons `opencode.ai/desktop-feedback` → docs root. The error page's report button `opencode.ai/desktop-feedback` → `github.com/quickbeard/codev-code/issues` instead (it's a report action, not a docs lookup), icon `discord` → `github`, and the en/es `error.page.report.*` strings → "…CoDev Code team" / "on GitHub". Help submenu (rendered by the shipped web UI's Windows app menu too): "Support Forum" (upstream Discord invite) deleted — the fork has no forum; "Share Feedback"/"Report a Bug" → `github.com/quickbeard/codev-code/issues/new?template=feature-request.yml`/`bug-report.yml` (the fork's template names are hyphenated, unlike upstream's underscored ones). | Fork users should land on the fork's docs and report issues to the fork, not upstream's site/Discord. NOT repointed: Zen/Go product URLs (`opencode.ai/zen`, `/go`, `/auth`), `$schema`/theme-schema ids, API/share/changelog endpoints, MCP OAuth `client_uri`, prompt-file mentions. Also NOT on this base: the ripgrep mirror (`/codev/docs/code/downloads/…`, see its own row) — it is a `public/` static asset, not locale-routed content, and 404s under `/en/docs/code`. | Keep our URLs, icon, and strings; take upstream's surrounding changes. Repoint any new browser-opened `opencode.ai` docs/help link to the fork docs base; leave the NOT-repointed list. |
| `packages/desktop/src/main/index.ts`, `packages/desktop/src/main/windows.ts` | APP_NAMES + window title → "CoDev Code" (Dev/Beta variants too) | Desktop app display name. | Keep our names. |
-| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev Code…"; artifactName → `codev-code-desktop-…`; protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId (`ai.opencode.desktop`) and the `opencode://` deep-link scheme stay upstream — identity plumbing wired into main/index.ts and app deep-links; do not rename casually. `rpm.packageName` (`opencode`/`opencode-beta`/`opencode-dev`) also stays — it names the installed Linux package, not a publish target. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource (covered by `electron-builder.config.test.ts`) — renaming it strands packaged sandbox providers. |
+| `packages/desktop/electron-builder.config.ts` | `productName` ×3 → "CoDev Code…"; artifactName → `codev-code-desktop-…`; protocol display name. Auto-update `publish` repointed off GitHub entirely: upstream's beta `anomalyco/opencode-beta` and prod `anomalyco/opencode` collapse to one shared `generic` config (`const publish`) pointing at the fork's MinIO bucket — `url` from `process.env.CODEV_UPDATE_URL` falling back to a base64 `UPDATE_URL` const (same `atob` convention as `packages/codev-brand/src/index.ts`), plus two fields that are load-bearing rather than cosmetic: `channel: "latest"` and `useMultipleRangeRequest: false`. Also a second `extraResources` entry shipping `../opencode/dist/node` → `server-bundle` (filter `node.js` + `*.wasm`, dropping the sourcemap, which outweighs the bundle) so the `@codev/sandbox` providers have a real on-disk server directory to mount into guests; `src/main/sandbox/bundle.ts` (fork-only) resolves it under `process.resourcesPath` by that name. Also the sandbox vz helper: `mac.extraResources` ships `packages/codev-sandbox/native/vzhelper/dist/codev-vmm` (built universal by `scripts/prebuild.ts`) as `Resources/codev-vmm`, and an `afterSign` hook (`signVmmHelper`) re-signs it with `vzhelper.entitlements` (`com.apple.security.virtualization` — an entitlement the Electron app itself must not carry) under the same identity that signed the app, hardened runtime on, timestamp only for non-ad-hoc, before electron-builder's built-in notarization runs. | Installer/product branding, and a packaged build must not offer upstream OpenCode releases as CoDev updates. GitHub could never have worked here: `quickbeard/codev-code` is private and `GitHubProvider.getLatestTagName` requests `/releases/latest` unauthenticated, and the repo has never carried a desktop asset. A bucket also splits nothing — one channel, one prefix. **`channel: "latest"` is required, not a default restated**: `detectUpdateChannel` is on by default, so with it absent `getResolvedPublishConfig` takes the channel from `AppInfo.channel`, which is the version's first semver prerelease component — `8` for `1.18.4-8` — and `getUpdateInfoFileName` then writes `8.yml`/`8-mac.yml`/`8-linux.yml` while the app requests `latest.yml` (`autoUpdater.channel` in `src/main/updater.ts`), i.e. a permanent `ERR_UPDATER_CHANNEL_FILE_NOT_FOUND`. **`useMultipleRangeRequest: false` is required for MinIO**, which answers a multipart range request with the whole object; electron-updater hardcodes the same `false` for its own `s3` provider citing minio/minio#5285 but defaults it to `true` for any `generic` URL that is not `s3.amazonaws.com`. Beta keeps a `publish` block despite the fork shipping no beta installers because `UPDATER_ENABLED` covers that channel and a build with no publish config gets no `app-update.yml` at all, so every check would fail with ENOENT. `${channel}` is deliberately NOT used as a URL macro — `macroExpander` expands it to that same `AppInfo.channel` (`8`), not `CODEV_CHANNEL`. appId (`ai.opencode.desktop`) and the `opencode://` deep-link scheme stay upstream — identity plumbing wired into main/index.ts and app deep-links; do not rename casually. `rpm.packageName` (`opencode`/`opencode-beta`/`opencode-dev`) also stays — it names the installed Linux package, not a publish target. | Keep our names and the whole `publish` const; take upstream's other config. Never drop `channel` or `useMultipleRangeRequest` to "match upstream" — both are covered by `electron-builder.config.test.ts`. If upstream reworks its publish targets, re-point ours rather than merging theirs. Note `generic` has no publisher (`createPublisher` returns null), so installers reach the bucket by hand; build with `--publish never`. Keep the `server-bundle` extraResource and the `codev-vmm` extraResource + `afterSign` hook (all covered by `electron-builder.config.test.ts`) — renaming either strands packaged sandbox providers, and dropping `afterSign` ships a helper without the virtualization entitlement, which fails at VM creation (or fails notarization if left ad-hoc signed). |
| `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). |
| Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. |
| Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` workspace devDependency (bundled into the main process by electron-vite, so not a runtime dependency). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. |
@@ -194,6 +194,7 @@ from the squash message first, or use a plain merge.
| `script/publish.ts` | The `if (Script.release)` block calling `packages/desktop/scripts/finalize-latest-{json,yml}.ts` removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform `latest*.yml` next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. |
| `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. |
| `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for ``. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. |
+| `packages/desktop/scripts/prebuild.ts` | On macOS, after the node-sidecar build, runs `bun script/build-vzhelper.ts --universal` in `packages/codev-sandbox` so `dist/codev-vmm` exists (universal) before electron-builder copies it via `mac.extraResources`. | The sandbox vz backend spawns the helper from `process.resourcesPath/codev-vmm` in packaged builds; universal keeps one already-fat Mach-O valid for `--arm64`, `--x64`, and `--universal` packaging (an identical thin binary in both halves would trip `@electron/universal`'s identical-file check). | Keep the darwin-gated build line; take upstream's other steps. It must stay after nothing in particular but before packaging — prebuild is the hook `package:*` runs. |
| `packages/desktop/scripts/predev.ts` | Runs `bun run install-electron` before the existing icon copy and node-sidecar build. | `bun dev:desktop` was dead on a fresh clone: Electron 42 removed the `postinstall` hook that downloaded the platform binary and exposes it as an `install-electron` bin the consumer must call (electron 41's manifest still had `"postinstall": "node install.js"`; 42.3.3's has no `scripts` field at all), so `bun install` leaves `node_modules/electron` with no `dist/` and `electron-vite dev` aborts with `Error: Electron uninstall` from `getElectronPath`. `predev` is the right hook rather than the root `postinstall`: only someone actually launching the desktop app pays the ~100 MB download, and `install.js` short-circuits once `dist/version` and `path.txt` match, so repeat runs cost nothing. Not added to `prebuild`/`package` — `electron-vite build` never spawns Electron, and `electron-builder` fetches its own copy through `@electron/get`. | Keep our installer line at the top; take upstream's other steps. Drop the line if upstream adds its own binary-install step or pins Electron back below 42. |
| `packages/ui/src/assets/favicon/` (favicon-v3.svg, favicon-v3.ico, site.webmanifest, favicon-96x96-v3.png, apple-touch-icon-v3.png, web-app-manifest-192/512.png) | New codev mark: the blocky "C" in brand red `#ee0033` on a white tile, generated from the same geometry as the app icon by `packages/desktop/scripts/generate-icons.ts` (4x5 grid, one-unit stroke, unit = 120/1024, corner radius 229/1024); `favicon-v3.svg` is the hand-written 512-unit source of that geometry. The tab and `.ico` icons keep the rounded tile so they read as an app icon; `apple-touch-icon-v3.png` and the two maskable `web-app-manifest-*` icons are square and full-bleed, because iOS and PWA launchers apply their own mask and a pre-rounded icon leaves transparent corners that render black. Manifest name "CoDev Code"/"codev"; its `theme_color`/`background_color` stay `#080808` — those describe the PWA's chrome and splash, not the icon. **Was `#00cdcd` on a dark tile** until this row was rewritten: PR #10 pinned the favicon to the then-cyan TUI banner accent, that banner later moved to the navy/red wordmark, and the favicon was left behind as the only cyan in the tree — visibly so on desktop, where the dock icon was red while the window and notification icons were cyan. | Every surface that draws the mark should draw the same mark: the dock icon, the tab, notifications, the `codev --mini` badge and the landing page icon are now one geometry in one red. Cyan on `#131010` was the higher-contrast tile at 16px (~9.4:1 versus red's ~4.2:1) — a real cost, accepted so the brand reads consistently. | Keep our assets (binary: take ours wholesale). Regenerate with the script rather than hand-editing, and edit `icons/icon.svg` + `favicon-v3.svg` together if the geometry ever changes. |
| `AGENTS.md` | Default-branch note: `dev` → `main` (single merged line; upstream has two lines saying local `main` may not exist); added fork bullets requiring every upstream-file change to get a `DIVERGENCES.md` row (and matching en + es doc updates) in the same PR, and pointing agents at this file's Divergence policy (prefer config/plugins, then `packages/codev-*`, upstream edits last) and Syncing with upstream sections | The fork's default branch is `main` (see the workflow-triggers row); agents kept basing diffs on a nonexistent `dev`. Agents edit upstream files without recording the divergence — and don't apply the placement policy or sync runbook — unless the instructions file says to. | Keep our lines; take upstream's other guidance. |
diff --git a/packages/codev-sandbox/script/build-vzhelper.ts b/packages/codev-sandbox/script/build-vzhelper.ts
index b9d8166802..95a1ace699 100644
--- a/packages/codev-sandbox/script/build-vzhelper.ts
+++ b/packages/codev-sandbox/script/build-vzhelper.ts
@@ -2,18 +2,32 @@
// Builds the codev-vmm Virtualization.framework helper and ad-hoc signs it
// with the virtualization entitlement so it can run VMs in development.
// Release packaging re-signs it with the desktop app's real identity.
-// Pass --universal to build arm64 + x86_64 (requires both toolchain slices).
+// Pass --universal to build arm64 + x86_64. Each slice is built with --triple
+// and merged with lipo: passing swift build several --arch flags hands the
+// build to xcbuild, which only full Xcode ships — Command Line Tools setups
+// error with "xcbuild executable ... does not exist".
import { $ } from "bun"
import { fileURLToPath } from "node:url"
const root = fileURLToPath(new URL("../native/vzhelper/", import.meta.url))
const universal = process.argv.includes("--universal")
-const archFlags = universal ? ["--arch", "arm64", "--arch", "x86_64"] : []
-const productDir = universal ? ".build/apple/Products/Release" : ".build/release"
-await $`swift build -c release --package-path ${root} ${archFlags}`
-const binary = `${root}${productDir}/codev-vmm`
-await $`codesign --force --sign - --entitlements ${root}vzhelper.entitlements ${binary}`
+const slices = await (async () => {
+ if (!universal) {
+ await $`swift build -c release --package-path ${root}`
+ return [`${root}.build/release/codev-vmm`]
+ }
+ const triples = ["arm64-apple-macosx", "x86_64-apple-macosx"]
+ for (const triple of triples) {
+ await $`swift build -c release --package-path ${root} --triple ${triple}`
+ }
+ return triples.map((triple) => `${root}.build/${triple}/release/codev-vmm`)
+})()
+
await $`mkdir -p ${root}dist`
-await $`cp ${binary} ${root}dist/codev-vmm`
+if (slices.length === 1) await $`cp ${slices[0]} ${root}dist/codev-vmm`
+else await $`lipo -create ${slices} -output ${root}dist/codev-vmm`
+// Sign the merged binary, not the slices: codesign covers every slice of a
+// fat Mach-O, and lipo would strip a per-slice signature anyway.
+await $`codesign --force --sign - --entitlements ${root}vzhelper.entitlements ${root}dist/codev-vmm`
console.log(`built ${root}dist/codev-vmm${universal ? " (universal)" : ""}`)
diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts
index 1bb24c7558..27f363ec97 100644
--- a/packages/desktop/electron-builder.config.test.ts
+++ b/packages/desktop/electron-builder.config.test.ts
@@ -123,6 +123,28 @@ test("ships the server bundle as a mountable resource", async () => {
expect(bundle?.filter).toEqual(["node.js", "*.wasm"])
})
+// The vz sandbox backend spawns this helper from process.resourcesPath; it
+// must ship under its exact name, and afterSign must be wired so it gets
+// re-signed with the virtualization entitlement before notarization.
+test("ships and re-signs the codev-vmm helper on macOS", async () => {
+ const previous = process.env.CODEV_CHANNEL
+ process.env.CODEV_CHANNEL = "prod"
+
+ const module = await import("./electron-builder.config.ts?vmm=prod")
+ const config = module.default as Configuration
+
+ if (previous === undefined) delete process.env.CODEV_CHANNEL
+ else process.env.CODEV_CHANNEL = previous
+
+ const resources = config.mac?.extraResources
+ expect(Array.isArray(resources)).toBe(true)
+ const helper = (resources as Exclude)
+ .filter((entry) => typeof entry !== "string")
+ .find((entry) => entry.to === "codev-vmm")
+ expect(helper?.from).toEndWith("codev-sandbox/native/vzhelper/dist/codev-vmm")
+ expect(typeof config.afterSign).toBe("function")
+})
+
test("keeps a hidden prod launcher for old Linux pins", async () => {
const previous = process.env.CODEV_CHANNEL
process.env.CODEV_CHANNEL = "prod"
diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts
index 199d4a5d65..78db010d20 100644
--- a/packages/desktop/electron-builder.config.ts
+++ b/packages/desktop/electron-builder.config.ts
@@ -1,9 +1,10 @@
import { execFile } from "node:child_process"
+import { existsSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
import { promisify } from "node:util"
-import type { Configuration } from "electron-builder"
+import type { AfterPackContext, Configuration } from "electron-builder"
const execFileAsync = promisify(execFile)
const packageDir = path.dirname(fileURLToPath(import.meta.url))
@@ -26,6 +27,39 @@ async function signWindows(configuration: { path: string }) {
)
}
+// The Virtualization.framework helper for the sandbox vz backend, built by
+// scripts/prebuild.ts on macOS. It must carry com.apple.security.virtualization
+// — an entitlement the Electron app itself neither has nor needs — so the app
+// signing pass cannot cover it: afterSign re-signs it with its own entitlements
+// under the same identity, before electron-builder's built-in notarization.
+const vmmHelperSource = path.join(rootDir, "packages", "codev-sandbox", "native", "vzhelper", "dist", "codev-vmm")
+const vmmEntitlements = path.join(rootDir, "packages", "codev-sandbox", "native", "vzhelper", "vzhelper.entitlements")
+
+async function signVmmHelper(context: AfterPackContext) {
+ if (context.electronPlatformName !== "darwin") return
+ const appPath = path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`)
+ const helper = path.join(appPath, "Contents", "Resources", "codev-vmm")
+ if (!existsSync(helper)) return
+ // codesign reports on stderr; reuse whatever identity signed the app so the
+ // helper stays notarizable (or ad-hoc in local unsigned builds).
+ const report = await execFileAsync("codesign", ["--display", "--verbose=2", appPath])
+ const adhoc = report.stderr.includes("Signature=adhoc")
+ const identity = adhoc ? "-" : report.stderr.match(/^Authority=(.+)$/m)?.[1]
+ if (!identity) throw new Error(`could not determine the signing identity of ${appPath}`)
+ await execFileAsync("codesign", [
+ "--force",
+ "--options",
+ "runtime",
+ // The timestamp service rejects ad-hoc signatures.
+ ...(adhoc ? [] : ["--timestamp"]),
+ "--entitlements",
+ vmmEntitlements,
+ "--sign",
+ identity,
+ helper,
+ ])
+}
+
const channel = (() => {
const raw = process.env.CODEV_CHANNEL
if (raw === "dev" || raw === "beta" || raw === "prod") return raw
@@ -92,9 +126,20 @@ const getBase = (appId: string): Configuration => ({
filter: ["node.js", "*.wasm"],
},
],
+ afterSign: signVmmHelper,
mac: {
category: "public.app-category.developer-tools",
icon: `resources/icons/icon.icns`,
+ // Merged with the global extraResources. The helper is a universal binary
+ // (prebuild builds it with --universal), so `--universal` packaging copies
+ // one already-fat Mach-O through instead of tripping the identical-file
+ // check that x64ArchFiles below works around for the node_modules ones.
+ extraResources: [
+ {
+ from: vmmHelperSource,
+ to: "codev-vmm",
+ },
+ ],
// Everything under the unpacked node_modules is a prebuilt binary shipped
// for all platforms — node-pty (including its extension-less spawn-helper),
// @parcel/watcher, msgpackr-extract — not a per-arch build output, so the
diff --git a/packages/desktop/scripts/prebuild.ts b/packages/desktop/scripts/prebuild.ts
index 79b0e30afc..e361032552 100644
--- a/packages/desktop/scripts/prebuild.ts
+++ b/packages/desktop/scripts/prebuild.ts
@@ -8,3 +8,9 @@ await $`bun ./scripts/copy-icons.ts ${channel}`
await $`bun ./scripts/copy-metainfo.ts ${channel}`
await $`cd ../opencode && bun script/build-node.ts`
+
+// The sandbox vz backend needs the codev-vmm helper shipped as a mac
+// extraResource. Universal so one binary serves --arm64, --x64, and
+// --universal packaging — @electron/universal copies an already-fat Mach-O
+// through without tripping its identical-file check.
+if (process.platform === "darwin") await $`cd ../codev-sandbox && bun script/build-vzhelper.ts --universal`