Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 →
Expand Down
2 changes: 1 addition & 1 deletion DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<name>`; `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:<name>` 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:<name>`; `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:<name>` 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 `<link rel="icon">`. | 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. |
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
50 changes: 47 additions & 3 deletions packages/app/src/sandbox/dialog-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 = () => {
Expand All @@ -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
Expand All @@ -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({
Expand Down Expand Up @@ -114,6 +135,29 @@ export function DialogAddSandbox() {
{language.t("sandbox.add.choose")}
</ButtonV2>
</div>
<Show when={resourcesApply()}>
<div class="flex items-center gap-2">
<TextInputV2
class="flex-1"
appearance="large"
placeholder={language.t("sandbox.add.cpus")}
value={store.cpus}
disabled={store.adding}
onInput={(event) => setStore("cpus", event.currentTarget.value)}
/>
<TextInputV2
class="flex-1"
appearance="large"
placeholder={language.t("sandbox.add.memory")}
value={store.memoryGiB}
disabled={store.adding}
onInput={(event) => setStore("memoryGiB", event.currentTarget.value)}
/>
</div>
<Show when={!resourcesValid()}>
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.add.resourcesInvalid")}</p>
</Show>
</Show>
<Show
when={progress()}
fallback={<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.add.downloadHint")}</p>}
Expand Down
13 changes: 13 additions & 0 deletions packages/app/src/sandbox/resources.ts
Original file line number Diff line number Diff line change
@@ -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)
}
9 changes: 7 additions & 2 deletions packages/app/src/sandbox/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down Expand Up @@ -48,7 +53,7 @@ export type SandboxesPlatform = {
subscribe(cb: (event: SandboxesEvent) => void): () => void
availability(): Promise<SandboxAvailability>
// Resolves undefined when the create was cancelled mid-download.
add(name: string, projectDir: string): Promise<SandboxConfig | undefined>
add(name: string, projectDir: string, resources?: SandboxResources): Promise<SandboxConfig | undefined>
// Aborts the in-flight create/reset job for this sandbox name, if any.
cancel(name: string): Promise<void>
remove(id: string): Promise<void>
Expand Down
13 changes: 13 additions & 0 deletions packages/codev-sandbox/src/policy.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
})
18 changes: 18 additions & 0 deletions packages/codev-sandbox/src/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading
Loading