diff --git a/CHANGELOG.md b/CHANGELOG.md
index d87daffd9e..69145ac034 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,12 @@ file is the human-readable summary of what changed and why.
### Desktop / Web UI
+- **Per-sandbox CPU and memory limits (macOS)** — the Add sandbox dialog gains
+ optional CPUs and Memory fields, applied when the sandbox VM boots; leaving
+ them empty keeps the previous defaults (4 CPUs, 4 GB). The fields appear
+ only on macOS, where the VM backend can honour them: Windows sandboxes
+ follow the VM-global limits in the user's `.wslconfig`, and the Linux
+ backend has no per-sandbox limits.
- **Named egress rules for sandboxes** — administrators can grant sandboxes
extra brokered destinations beyond the model gateway by creating
`sandbox-egress.json` in the app's data directory: an object of name →
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index 5148af8d36..e7c4789f2c 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -190,7 +190,7 @@ from the squash message first, or use a plain merge.
| `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` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | 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. |
-| Desktop sandbox renderer slice: `packages/app/package.json`, `packages/app/src/{index.ts,app.tsx}`, `packages/app/src/context/{platform,server}.tsx`, `packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}`, `packages/app/src/wsl/settings.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Surfaces the fork-only sandbox modules (`packages/app/src/sandbox/*`, `packages/desktop/src/renderer/sandbox/*`) in the UI: app `package.json` exports `./sandbox/types` (mirror of `./wsl/types`); `index.ts` re-exports `useSandboxes` + the sandbox types; `app.tsx` mounts `SandboxesProvider` inside `WslServersProvider`; `platform.tsx` gains optional `sandboxes?: SandboxesPlatform`; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name }` with key `sandbox:`; `settings-v2/servers.tsx` filters sandbox rows out of the generic list and renders `SandboxServerSettings` beside the WSL section; `dialog-select-server.tsx` `handleRemove` routes `sandbox:` keys to `platform.sandboxes.remove` (like `wsl:` keys); `wsl/settings.tsx` `AddServerMenu` shows the menu when either wslServers or sandboxes exist and gains an "Add sandbox" item; i18n gains the `sandbox.*` key family; preload exposes `api.sandboxes` over the `sandbox-*` channels and `renderer/index.tsx` wires the platform api, loading gate, ready-connection list, and the `startupServer` fallback (fork-only wrapper that validates `sandbox:` default keys the way `availableStartupServer` validates `wsl:` ones). | Ready sandboxes must appear as first-class servers and be manageable from settings; the slice mirrors the WSL vertical exactly so upstream conflicts resolve the same way in both. | Keep every listed edit; take upstream's surrounding changes. If upstream adds a `Sidecar` variant or reworks `AddServerMenu`, re-apply the sandbox variant/menu item on top. The `sandbox:` key format is persisted as the default-server key and as `config.id` in the main-process store — never change one side alone. |
+| Desktop sandbox renderer slice: `packages/app/package.json`, `packages/app/src/{index.ts,app.tsx}`, `packages/app/src/context/{platform,server}.tsx`, `packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}`, `packages/app/src/wsl/settings.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Surfaces the fork-only sandbox modules (`packages/app/src/sandbox/*`, `packages/desktop/src/renderer/sandbox/*`) in the UI: app `package.json` exports `./sandbox/types` (mirror of `./wsl/types`); `index.ts` re-exports `useSandboxes` + the sandbox types; `app.tsx` mounts `SandboxesProvider` inside `WslServersProvider`; `platform.tsx` gains optional `sandboxes?: SandboxesPlatform`; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name }` with key `sandbox:`; `settings-v2/servers.tsx` filters sandbox rows out of the generic list and renders `SandboxServerSettings` beside the WSL section; `dialog-select-server.tsx` `handleRemove` routes `sandbox:` keys to `platform.sandboxes.remove` (like `wsl:` keys); `wsl/settings.tsx` `AddServerMenu` shows the menu when either wslServers or sandboxes exist and gains an "Add sandbox" item; i18n gains the `sandbox.*` key family (including the optional per-sandbox CPU/memory fields, macOS-only since only the vz backend applies them); preload exposes `api.sandboxes` over the `sandbox-*` channels and `renderer/index.tsx` wires the platform api, loading gate, ready-connection list, and the `startupServer` fallback (fork-only wrapper that validates `sandbox:` default keys the way `availableStartupServer` validates `wsl:` ones). | Ready sandboxes must appear as first-class servers and be manageable from settings; the slice mirrors the WSL vertical exactly so upstream conflicts resolve the same way in both. | Keep every listed edit; take upstream's surrounding changes. If upstream adds a `Sidecar` variant or reworks `AddServerMenu`, re-apply the sandbox variant/menu item on top. The `sandbox:` key format is persisted as the default-server key and as `config.id` in the main-process store — never change one side alone. |
| `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. |
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 4e7e202662..633e411f6f 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -391,6 +391,9 @@ export const dict = {
"sandbox.add.projectDir": "Project folder",
"sandbox.add.choose": "Choose...",
"sandbox.add.create": "Create",
+ "sandbox.add.cpus": "CPUs (optional)",
+ "sandbox.add.memory": "Memory GB (optional)",
+ "sandbox.add.resourcesInvalid": "CPUs must be 1-32 and memory 1-64 GB.",
"sandbox.add.downloadHint": "The first sandbox downloads its image; on a slow connection this can take a while.",
"sandbox.add.downloadProgress": "Downloading image… {{done}}/{{total}} MB",
"wsl.onboarding.step.distro": "Choose distro",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 0d007692da..69a1804834 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -382,6 +382,9 @@ export const dict = {
"sandbox.add.projectDir": "Carpeta del proyecto",
"sandbox.add.choose": "Elegir...",
"sandbox.add.create": "Crear",
+ "sandbox.add.cpus": "CPU (opcional)",
+ "sandbox.add.memory": "Memoria GB (opcional)",
+ "sandbox.add.resourcesInvalid": "Las CPU deben ser 1-32 y la memoria 1-64 GB.",
"sandbox.add.downloadHint": "El primer sandbox descarga su imagen; con una conexión lenta puede tardar un poco.",
"sandbox.add.downloadProgress": "Descargando imagen… {{done}}/{{total}} MB",
"wsl.onboarding.step.distro": "Elegir distribución",
diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx
index e829236cf3..7bc122144d 100644
--- a/packages/app/src/sandbox/dialog-add.tsx
+++ b/packages/app/src/sandbox/dialog-add.tsx
@@ -10,6 +10,7 @@ import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSandboxes } from "./context"
+import { sandboxResourcesValid } from "./resources"
// Distribution and directory names are derived from the sandbox name across
// every backend, so keep it to characters that are safe in all of them.
@@ -21,7 +22,20 @@ export function DialogAddSandbox() {
const dialog = useDialog()
const sandboxes = useSandboxes()
const api = platform.sandboxes!
- const [store, setStore] = createStore({ name: "", projectDir: "", adding: false })
+ const [store, setStore] = createStore({ name: "", projectDir: "", cpus: "", memoryGiB: "", adding: false })
+
+ // Only the macOS VM backend applies per-sandbox limits; WSL's are VM-global
+ // and bubblewrap has none, so the fields appear only where they take effect.
+ const resourcesApply = () => platform.os === "macos"
+ // Memory is entered in GiB (a MiB box invites 4-digit typing) and stored in
+ // MiB, which is what the VM config wants.
+ const cpus = () => (store.cpus.trim() ? Number(store.cpus) : undefined)
+ const memoryMiB = () => (store.memoryGiB.trim() ? Math.round(Number(store.memoryGiB) * 1024) : undefined)
+ const resourcesValid = () =>
+ !resourcesApply() ||
+ (sandboxResourcesValid({ cpus: cpus(), memoryMiB: memoryMiB() }) &&
+ !Number.isNaN(cpus() ?? 0) &&
+ !Number.isNaN(memoryMiB() ?? 0))
const availability = () => sandboxes.availability.data
const unsupportedMessage = () => {
@@ -34,7 +48,10 @@ export function DialogAddSandbox() {
}
const nameInvalid = () => store.name.length > 0 && !NAME_PATTERN.test(store.name)
const canCreate = () =>
- availability()?.supported === true && NAME_PATTERN.test(store.name) && store.projectDir.length > 0
+ availability()?.supported === true &&
+ NAME_PATTERN.test(store.name) &&
+ store.projectDir.length > 0 &&
+ resourcesValid()
const pickProjectDir = () => {
if (platform.platform !== "desktop") return
@@ -57,7 +74,11 @@ export function DialogAddSandbox() {
try {
// add resolves undefined when the download was cancelled: keep the
// dialog open with the form intact so the user can try again.
- const created = await api.add(store.name, store.projectDir)
+ const created = await api.add(
+ store.name,
+ store.projectDir,
+ resourcesApply() ? { cpus: cpus(), memoryMiB: memoryMiB() } : undefined,
+ )
if (created) dialog.close()
} catch (error) {
showToast({
@@ -114,6 +135,29 @@ export function DialogAddSandbox() {
{language.t("sandbox.add.choose")}
+
+