From a22adcb01f219e08d3f7cbbde6ddeb2607998406 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 13 Aug 2026 11:23:36 +0700 Subject: [PATCH] feat(sandbox): per-sandbox cpu and memory limits MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SandboxConfig gains optional cpus/memoryMiB with shared range validation; the vz provider prefers them over its provider-wide defaults, and the add dialog offers the fields on macOS only — WSL limits are VM-global (.wslconfig) and bubblewrap has no cgroup plumbing here, so offering them elsewhere would be a lie. Memory is entered in GB and stored as MiB. The main process re-validates on the IPC boundary, and a hand-edited store value that fails validation falls back to the provider defaults rather than dropping the sandbox. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 6 +++ DIVERGENCES.md | 2 +- packages/app/src/i18n/en.ts | 3 ++ packages/app/src/i18n/es.ts | 3 ++ packages/app/src/sandbox/dialog-add.tsx | 50 +++++++++++++++++-- packages/app/src/sandbox/resources.ts | 13 +++++ packages/app/src/sandbox/types.ts | 9 +++- packages/codev-sandbox/src/policy.test.ts | 13 +++++ packages/codev-sandbox/src/provider.ts | 18 +++++++ .../codev-sandbox/src/vz/provider.test.ts | 21 ++++++++ packages/codev-sandbox/src/vz/provider.ts | 6 ++- .../desktop/src/main/sandbox/controller.ts | 17 ++++++- packages/desktop/src/main/sandbox/ipc.ts | 40 ++++++++++----- packages/desktop/src/preload/index.ts | 2 +- 14 files changed, 181 insertions(+), 22 deletions(-) create mode 100644 packages/app/src/sandbox/resources.ts 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")} + +
+ setStore("cpus", event.currentTarget.value)} + /> + setStore("memoryGiB", event.currentTarget.value)} + /> +
+ +

{language.t("sandbox.add.resourcesInvalid")}

+
+
{language.t("sandbox.add.downloadHint")}

} diff --git a/packages/app/src/sandbox/resources.ts b/packages/app/src/sandbox/resources.ts new file mode 100644 index 0000000000..55125845a6 --- /dev/null +++ b/packages/app/src/sandbox/resources.ts @@ -0,0 +1,13 @@ +import type { SandboxResources } from "./types" + +// Mirrors SANDBOX_*_RANGE in @codev/sandbox: the app package must not import +// the node-flavored package, and the main process re-validates on the way in, +// so this copy only decides when the dialog's Create button is enabled. +export const SANDBOX_CPUS_RANGE = { min: 1, max: 32 } as const +export const SANDBOX_MEMORY_MIB_RANGE = { min: 1024, max: 65536 } as const + +export function sandboxResourcesValid(resources: SandboxResources) { + const inRange = (value: number | undefined, range: { min: number; max: number }) => + value === undefined || (Number.isInteger(value) && value >= range.min && value <= range.max) + return inRange(resources.cpus, SANDBOX_CPUS_RANGE) && inRange(resources.memoryMiB, SANDBOX_MEMORY_MIB_RANGE) +} diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts index f6e95575a5..032a6dae3e 100644 --- a/packages/app/src/sandbox/types.ts +++ b/packages/app/src/sandbox/types.ts @@ -10,7 +10,12 @@ export type SandboxConfig = { name: string backend: SandboxBackend projectDir: string -} +} & SandboxResources + +// Per-sandbox limits. Only the macOS vz backend applies them: WSL's limits are +// VM-global (.wslconfig) and bubblewrap has no cgroup plumbing here, so the UI +// offers the fields only where they take effect. +export type SandboxResources = { cpus?: number; memoryMiB?: number } export type SandboxRuntime = | { kind: "starting" } @@ -48,7 +53,7 @@ export type SandboxesPlatform = { subscribe(cb: (event: SandboxesEvent) => void): () => void availability(): Promise // Resolves undefined when the create was cancelled mid-download. - add(name: string, projectDir: string): Promise + add(name: string, projectDir: string, resources?: SandboxResources): Promise // Aborts the in-flight create/reset job for this sandbox name, if any. cancel(name: string): Promise remove(id: string): Promise diff --git a/packages/codev-sandbox/src/policy.test.ts b/packages/codev-sandbox/src/policy.test.ts index b777e24ffb..cdb78486c4 100644 --- a/packages/codev-sandbox/src/policy.test.ts +++ b/packages/codev-sandbox/src/policy.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test" +import { SANDBOX_CPUS_RANGE, SANDBOX_MEMORY_MIB_RANGE, sandboxResourcesValid } from "./provider" import { MACOS_UNSUPPORTED_MESSAGE, WINDOWS_10_WARNING, @@ -38,3 +39,15 @@ test("reads the Windows build number from os.release()", () => { test("linux is always eligible at the policy layer", () => { expect(linuxSandboxAvailability()).toEqual({ supported: true, warning: null }) }) + +test("sandbox resource limits accept sane values and reject the rest", () => { + expect(sandboxResourcesValid({})).toBe(true) + expect(sandboxResourcesValid({ cpus: 4, memoryMiB: 8192 })).toBe(true) + expect(sandboxResourcesValid({ cpus: SANDBOX_CPUS_RANGE.max, memoryMiB: SANDBOX_MEMORY_MIB_RANGE.max })).toBe(true) + expect(sandboxResourcesValid({ cpus: 0 })).toBe(false) + expect(sandboxResourcesValid({ cpus: 33 })).toBe(false) + expect(sandboxResourcesValid({ cpus: 2.5 })).toBe(false) + expect(sandboxResourcesValid({ memoryMiB: 512 })).toBe(false) + expect(sandboxResourcesValid({ memoryMiB: 131072 })).toBe(false) + expect(sandboxResourcesValid({ cpus: Number.NaN })).toBe(false) +}) diff --git a/packages/codev-sandbox/src/provider.ts b/packages/codev-sandbox/src/provider.ts index b00c6b295b..7356584412 100644 --- a/packages/codev-sandbox/src/provider.ts +++ b/packages/codev-sandbox/src/provider.ts @@ -7,6 +7,24 @@ export type SandboxConfig = { name: string backend: SandboxBackend projectDir: string + // Per-sandbox limits, applied by the vz backend at boot. Omitted means the + // provider default. WSL and bubblewrap ignore them: WSL's limits live in + // the user's VM-global .wslconfig, and bubblewrap has no cgroup plumbing + // here yet — so the UI only offers them where they take effect. + cpus?: number + memoryMiB?: number +} + +// Ceilings keep an unusable VM (0 cpus, 64MiB) or an oversubscribed host out +// of the persisted config; the guest needs real headroom for the office +// toolchain. Both ends inclusive. +export const SANDBOX_CPUS_RANGE = { min: 1, max: 32 } as const +export const SANDBOX_MEMORY_MIB_RANGE = { min: 1024, max: 65536 } as const + +export function sandboxResourcesValid(config: { cpus?: number; memoryMiB?: number }) { + const inRange = (value: number | undefined, range: { min: number; max: number }) => + value === undefined || (Number.isInteger(value) && value >= range.min && value <= range.max) + return inRange(config.cpus, SANDBOX_CPUS_RANGE) && inRange(config.memoryMiB, SANDBOX_MEMORY_MIB_RANGE) } export type SandboxRuntime = diff --git a/packages/codev-sandbox/src/vz/provider.test.ts b/packages/codev-sandbox/src/vz/provider.test.ts index 3af04d729c..df9c8692ba 100644 --- a/packages/codev-sandbox/src/vz/provider.test.ts +++ b/packages/codev-sandbox/src/vz/provider.test.ts @@ -130,6 +130,8 @@ test("starts the VM via the helper protocol and serves health through the vsock "host-to-guest", "guest-to-host", ]) + expect(vmConfig.cpus).toBe(4) + expect(vmConfig.memoryMiB).toBe(4096) const boot = JSON.parse(await readFile(join(prepared.dirs.guestConfig, "boot.json"), "utf8")) expect(boot.env.CODEV_SERVER_PASSWORD).toBe(sandbox.password) expect(boot.env.CODEV_SERVER_HOSTNAME).toBe("127.0.0.1") @@ -147,6 +149,25 @@ test("starts the VM via the helper protocol and serves health through the vsock await rm(dir, { recursive: true, force: true }) }) +test("per-sandbox cpus and memory win over the provider defaults", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-vz-")) + const prepared = await preparedOptions(dir, FAKE_HELPER) + const provider = createVzProvider({ ...prepared.options, cpus: 2, memoryMiB: 2048 }) + + const sandbox = await provider.start({ + ...config, + projectDir: prepared.projectDir, + cpus: 8, + memoryMiB: 16384, + }) + const vmConfig = JSON.parse(await readFile(join(prepared.dirs.runtime, "vm.json"), "utf8")) + expect(vmConfig.cpus).toBe(8) + expect(vmConfig.memoryMiB).toBe(16384) + + sandbox.stop() + await rm(dir, { recursive: true, force: true }) +}) + test("surfaces helper error events with the stderr tail", async () => { const dir = await mkdtemp(join(tmpdir(), "codev-vz-")) const prepared = await preparedOptions(dir, FAILING_HELPER) diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts index 186f1daa09..26687aef41 100644 --- a/packages/codev-sandbox/src/vz/provider.ts +++ b/packages/codev-sandbox/src/vz/provider.ts @@ -37,6 +37,7 @@ export type VzProviderOptions = { // socket, plus an optional guest codev config announced via CODEV_CONFIG. // Absent or null, the guest boots with no egress at all. resolveEgress?: () => Promise + // Defaults for sandboxes that do not set their own. cpus?: number memoryMiB?: number platform?: NodeJS.Platform @@ -153,8 +154,9 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { initrd: initrd?.isFile() ? join(dirs.image, VZ_INITRD_FILE) : "", disk: dirs.disk, cmdline: VZ_CMDLINE, - cpus: options.cpus ?? 4, - memoryMiB: options.memoryMiB ?? 4096, + // Per-sandbox limits win over the provider-wide defaults. + cpus: config.cpus ?? options.cpus ?? 4, + memoryMiB: config.memoryMiB ?? options.memoryMiB ?? 4096, shares: [ { tag: "workspace", path: config.projectDir, readOnly: false }, { tag: "bundle", path: options.bundleDir, readOnly: true }, diff --git a/packages/desktop/src/main/sandbox/controller.ts b/packages/desktop/src/main/sandbox/controller.ts index 015c3c60ab..c0e6080115 100644 --- a/packages/desktop/src/main/sandbox/controller.ts +++ b/packages/desktop/src/main/sandbox/controller.ts @@ -9,6 +9,7 @@ import { createWslProvider, fetchSandboxManifest, requireImageAsset, + sandboxResourcesValid, } from "@codev/sandbox" import type { SandboxArch, SandboxAvailability, SandboxBackend, SandboxConfig, SandboxProvider } from "@codev/sandbox" import { getStore } from "../store" @@ -101,5 +102,19 @@ function normalizePersistedSandbox(value: unknown): SandboxConfig[] { if (typeof record.name !== "string" || !record.name.length) return [] if (record.backend !== "vz" && record.backend !== "wsl" && record.backend !== "bwrap") return [] if (typeof record.projectDir !== "string" || !record.projectDir.length) return [] - return [{ id: record.id, name: record.name, backend: record.backend, projectDir: record.projectDir }] + const resources = { + ...(typeof record.cpus === "number" ? { cpus: record.cpus } : {}), + ...(typeof record.memoryMiB === "number" ? { memoryMiB: record.memoryMiB } : {}), + } + // A persisted file edited by hand can carry nonsense; fall back to the + // provider defaults rather than refusing to load the sandbox at all. + return [ + { + id: record.id, + name: record.name, + backend: record.backend, + projectDir: record.projectDir, + ...(sandboxResourcesValid(resources) ? resources : {}), + }, + ] } diff --git a/packages/desktop/src/main/sandbox/ipc.ts b/packages/desktop/src/main/sandbox/ipc.ts index 203d185b0c..3ffda87155 100644 --- a/packages/desktop/src/main/sandbox/ipc.ts +++ b/packages/desktop/src/main/sandbox/ipc.ts @@ -1,6 +1,6 @@ import { app, ipcMain } from "electron" import type { IpcMainInvokeEvent } from "electron" -import { sandboxServerId } from "@codev/sandbox" +import { sandboxResourcesValid, sandboxServerId } from "@codev/sandbox" import type { DesktopSandboxController } from "./controller" // Unlike the WSL channels there is no unavailable-stub variant: the controller @@ -38,17 +38,21 @@ export function registerSandboxIpcHandlers(controller: DesktopSandboxController) ipcMain.handle("sandbox-unsubscribe", (event) => unsubscribe(event.sender.id)) ipcMain.handle("sandbox-get-state", () => controller.getState()) ipcMain.handle("sandbox-availability", () => controller.availability()) - ipcMain.handle("sandbox-add", (_event: IpcMainInvokeEvent, name: unknown, projectDir: unknown) => { - const backend = controller.backend - if (!backend) throw new Error(`sandboxes are not supported on ${process.platform}`) - const sandboxName = requireSandboxIpcString("sandbox name", name) - return controller.addSandbox({ - id: sandboxServerId(sandboxName), - name: sandboxName, - backend, - projectDir: requireSandboxIpcString("project directory", projectDir), - }) - }) + ipcMain.handle( + "sandbox-add", + (_event: IpcMainInvokeEvent, name: unknown, projectDir: unknown, resources: unknown) => { + const backend = controller.backend + if (!backend) throw new Error(`sandboxes are not supported on ${process.platform}`) + const sandboxName = requireSandboxIpcString("sandbox name", name) + return controller.addSandbox({ + id: sandboxServerId(sandboxName), + name: sandboxName, + backend, + projectDir: requireSandboxIpcString("project directory", projectDir), + ...requireSandboxResources(resources), + }) + }, + ) // Cancel takes the name (like add) so the renderer never mints sandbox ids. ipcMain.handle("sandbox-cancel", (_event: IpcMainInvokeEvent, name: unknown) => controller.cancelJob(sandboxServerId(requireSandboxIpcString("sandbox name", name))), @@ -68,3 +72,15 @@ function requireSandboxIpcString(name: string, value: unknown) { if (typeof value === "string" && value.length > 0) return value throw new Error(`Invalid ${name}`) } + +function requireSandboxResources(value: unknown) { + if (value === undefined || value === null) return {} + if (typeof value !== "object") throw new Error("Invalid sandbox resources") + const record = value as Record + const resources = { + ...(record.cpus === undefined ? {} : { cpus: Number(record.cpus) }), + ...(record.memoryMiB === undefined ? {} : { memoryMiB: Number(record.memoryMiB) }), + } + if (!sandboxResourcesValid(resources)) throw new Error("Invalid sandbox resources") + return resources +} diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index c6d4dc4c6e..e652ae2253 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -48,7 +48,7 @@ const api: ElectronAPI = { } }, availability: () => ipcRenderer.invoke("sandbox-availability"), - add: (name, projectDir) => ipcRenderer.invoke("sandbox-add", name, projectDir), + add: (name, projectDir, resources) => ipcRenderer.invoke("sandbox-add", name, projectDir, resources), cancel: (name) => ipcRenderer.invoke("sandbox-cancel", name), remove: (id) => ipcRenderer.invoke("sandbox-remove", id), reset: (id) => ipcRenderer.invoke("sandbox-reset", id),