From 4c45bc97984948a409ede3b6f050af4027c27aeb Mon Sep 17 00:00:00 2001
From: minhn4
Date: Sat, 15 Aug 2026 10:49:24 +0700
Subject: [PATCH 1/2] fix(sandbox): queue lifecycle jobs instead of cancelling
the last one
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Create, reset and destroy shared one abort controller and one state slot, so
beginJob aborted whatever was in flight regardless of which sandbox it belonged
to. Deleting or resetting sandbox B part-way through sandbox A's image download
silently abandoned that download: the cancelled create resolves undefined, which
addSandbox treats as a clean cancel, so nothing was persisted and no error was
raised. The UI only disabled actions for the sandbox holding the job, so every
other sandbox's menu stayed live and could trigger it.
Jobs now queue. They still run one at a time — create and reset stage images
through one shared cache whose resumable-download bookkeeping assumes a single
writer (image.ts removeStalePartials) — but each one waits its turn instead of
killing the one ahead of it. Only a second job for the *same* sandbox supersedes
the first, which is what the single-flight guard was for. Cancelling a job that
is still queued returns immediately rather than waiting out the running job, and
the one behind it does not jump ahead of the job actually running.
State exposes `jobs: SandboxJob[]` (running first) in place of `job`, each with
a `status`, so the app can tell "waiting behind another sandbox" from "working".
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 9 +
DIVERGENCES.md | 2 +-
packages/app/src/i18n/en.ts | 1 +
packages/app/src/i18n/es.ts | 1 +
packages/app/src/sandbox/dialog-add.tsx | 16 +-
packages/app/src/sandbox/dialog-mounts.tsx | 2 +-
packages/app/src/sandbox/settings.tsx | 13 +-
packages/app/src/sandbox/types.ts | 8 +-
packages/codev-sandbox/README.md | 206 ++++++++++++------
packages/codev-sandbox/src/controller.test.ts | 138 +++++++++++-
packages/codev-sandbox/src/controller.ts | 106 +++++----
11 files changed, 378 insertions(+), 124 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index aa1baab8ab..957a122964 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,15 @@ file is the human-readable summary of what changed and why.
### Desktop / Web UI
+- **Working on one sandbox no longer cancels another one's work** — creating,
+ resetting, and deleting a sandbox all ran as a single operation with no
+ regard for which sandbox they belonged to, so starting one cancelled
+ whichever was already running. Deleting a sandbox part-way through another
+ sandbox's image download silently abandoned that download, and the half-built
+ sandbox was never created — with no error to say why. These operations now
+ queue: each one runs in turn and finishes. A sandbox waiting its turn says
+ so, and cancelling it (or the **Cancel** button in the Add sandbox dialog)
+ still takes effect immediately without disturbing whatever is running.
- **A stalling disk can no longer freeze the app** — saving any setting
(window layout, server list, sandbox folders) used to write to disk
synchronously on the app's main thread. If the operating system's disk
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index e3731211fc..ce019aa2f8 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -197,7 +197,7 @@ from the squash message first, or use a plain merge.
| 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. |
| Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. |
| 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; projectDir?; mounts? }` with key `sandbox:` (`projectDir` is the host folder mounted into the guest and `mounts` the extra host folders with their resolved guest paths — the guest only ever sees its own mount points, so this is the only way the app can name a sandbox's projects after the folders it works on); `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) and moves the `tabs.removeServer(key)` + `server.remove(key)` pair *above* both awaited platform removals; `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; `sandbox.add.projectDirHint`, the standing explanation above the folder field; and the `sandbox.mounts.*` family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox); 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. Removing a server destroys a disk and takes seconds, and the connection leaves the list the moment it finishes, so the tab cleanup has to happen before the await rather than after it. | 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. Keep the tab cleanup ahead of the platform removals in `handleRemove`; if upstream reorders it back, re-apply. |
+| 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; projectDir?; mounts? }` with key `sandbox:` (`projectDir` is the host folder mounted into the guest and `mounts` the extra host folders with their resolved guest paths — the guest only ever sees its own mount points, so this is the only way the app can name a sandbox's projects after the folders it works on); `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) and moves the `tabs.removeServer(key)` + `server.remove(key)` pair *above* both awaited platform removals; `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; `sandbox.add.projectDirHint`, the standing explanation above the folder field; the `sandbox.mounts.*` family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox; and `sandbox.job.queued`, shown while a sandbox's create/reset/destroy waits its turn behind another sandbox's job); 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. Removing a server destroys a disk and takes seconds, and the connection leaves the list the moment it finishes, so the tab cleanup has to happen before the await rather than after it. | 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. Keep the tab cleanup ahead of the platform removals in `handleRemove`; if upstream reorders it back, re-apply. |
| Desktop identity: `packages/desktop/src/main/{index,store}.ts`, `packages/desktop/electron-builder.config{,.test}.ts`, `packages/desktop/scripts/copy-metainfo.ts`, `packages/desktop/resources/linux/opencode-desktop.desktop` (deleted), `packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}`, `packages/codev-brand/test/brand.test.ts` | appId `ai.opencode.desktop{,.beta,.dev}` → `ai.codev.desktop{,.beta,.dev}` (both copies: `APP_IDS` in main and in the builder config, plus the unpackaged fallback); deep-link scheme `opencode://` → `codev://` in all three places that must agree — `protocols.schemes`, `app.setAsDefaultProtocolClient`, and the renderer's `parseUrl` — plus the `argv.startsWith` filter the second-instance handler uses; `rpm.packageName` `opencode`/`opencode-beta`/`opencode-dev` → `codev`/`codev-beta`/`codev-dev`; the hidden `opencode-desktop.desktop` legacy launcher and its `deb`/`rpm` fpm wiring deleted. `src/main/migrate.ts` keeps the old ids — they name the *Tauri* app's directories, not ours. | The appId is the whole desktop identity: `app.setPath("userData", join(appData, appId))`, so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux `.desktop` filename, `executableName` and `StartupWMClass`, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under `appData/ai.opencode.desktop*` are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its `Exec=/opt/OpenCode/...` no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the `TAURI_APP_IDS` in `migrate.ts` to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; `electron-builder.config.test.ts` covers the ids, the package names and the scheme per channel. |
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index cadc0904a0..96a1ee7b1f 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -391,6 +391,7 @@ export const dict = {
"sandbox.job.create": "Creating...",
"sandbox.job.destroy": "Removing...",
"sandbox.job.reset": "Resetting...",
+ "sandbox.job.queued": "Waiting for another sandbox...",
"sandbox.add.namePlaceholder": "Sandbox name",
"sandbox.add.nameInvalid": "Use letters, numbers, dots, dashes, or underscores.",
"sandbox.add.projectDir": "Project folder",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 58b6b7cb37..d875ce383e 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -382,6 +382,7 @@ export const dict = {
"sandbox.job.create": "Creando...",
"sandbox.job.destroy": "Eliminando...",
"sandbox.job.reset": "Restableciendo...",
+ "sandbox.job.queued": "Esperando a otro sandbox...",
"sandbox.add.namePlaceholder": "Nombre del sandbox",
"sandbox.add.nameInvalid": "Usa letras, números, puntos, guiones o guiones bajos.",
"sandbox.add.projectDir": "Carpeta del proyecto",
diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx
index 0e3ff43eac..2f2f503f35 100644
--- a/packages/app/src/sandbox/dialog-add.tsx
+++ b/packages/app/src/sandbox/dialog-add.tsx
@@ -65,13 +65,15 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) =>
})
}
- // The create job's image-download progress, pushed by the main process
- // while this dialog's add is in flight.
+ // This create's image-download progress, pushed by the main process while
+ // the add is in flight. Jobs are queued, so the create may still be waiting
+ // behind another sandbox's job: no progress until it starts running.
const progress = () => {
if (!store.adding) return undefined
- const job = sandboxes.state.data?.job
- return job?.kind === "create" ? job.progress : undefined
+ return sandboxes.state.data?.jobs.find((job) => job.kind === "create" && job.status === "running")?.progress
}
+ const queued = () =>
+ store.adding && sandboxes.state.data?.jobs.some((job) => job.kind === "create" && job.status === "queued")
const create = async () => {
if (!canCreate() || store.adding) return
@@ -172,7 +174,11 @@ export function DialogAddSandbox(props: { onCreated?: (config: SandboxConfig) =>
{language.t("sandbox.add.downloadHint")}
diff --git a/packages/app/src/sandbox/dialog-mounts.tsx b/packages/app/src/sandbox/dialog-mounts.tsx
index 3fa14a63a7..a3622b75af 100644
--- a/packages/app/src/sandbox/dialog-mounts.tsx
+++ b/packages/app/src/sandbox/dialog-mounts.tsx
@@ -30,7 +30,7 @@ export function DialogSandboxMounts(props: { id: string }) {
saving: false,
})
- const busy = () => sandboxes.state.data?.job?.id === props.id
+ const busy = () => !!sandboxes.state.data?.jobs.some((job) => job.id === props.id)
const dirty = () => {
const current = (item()?.config.mounts ?? []).map((mount) => mount.path)
return current.length !== store.paths.length || current.some((path, index) => path !== store.paths[index])
diff --git a/packages/app/src/sandbox/settings.tsx b/packages/app/src/sandbox/settings.tsx
index 15a162a1d8..e31ecd2262 100644
--- a/packages/app/src/sandbox/settings.tsx
+++ b/packages/app/src/sandbox/settings.tsx
@@ -63,11 +63,16 @@ export function SandboxServerSettings(props: {
{(item) => {
const key = ServerConnection.Key.make(item.config.id)
- // Create/reset/destroy jobs are single-flight in the controller, so a
- // job on this sandbox means its lifecycle actions must wait.
- const busy = () => sandboxes.state.data?.job?.id === item.config.id
+ // A sandbox has at most one job, running or queued behind another
+ // sandbox's; either way its lifecycle actions must wait.
+ const job = () => sandboxes.state.data?.jobs.find((entry) => entry.id === item.config.id)
+ const busy = () => !!job()
const meta = () => {
- if (busy()) return language.t(`sandbox.job.${sandboxes.state.data!.job!.kind}`)
+ const current = job()
+ if (current)
+ return current.status === "queued"
+ ? language.t("sandbox.job.queued")
+ : language.t(`sandbox.job.${current.kind}`)
if (item.runtime.kind === "failed") return item.runtime.message
return `${item.config.backend} · ${language.t(`sandbox.status.${item.runtime.kind}`)}`
}
diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts
index fd052cc960..36250e5441 100644
--- a/packages/app/src/sandbox/types.ts
+++ b/packages/app/src/sandbox/types.ts
@@ -48,13 +48,19 @@ export type SandboxProgress = { bytes: number; totalBytes: number }
export type SandboxJob = {
kind: "create" | "destroy" | "reset"
id: string
+ // When the job was admitted to the queue, which is not when it started
+ // running — status says which of those has happened.
startedAt: number
+ status: "running" | "queued"
progress?: SandboxProgress
}
export type SandboxesState = {
sandboxes: SandboxItem[]
- job: SandboxJob | null
+ // Create/reset/destroy run one at a time across all sandboxes (they share
+ // one image cache), so a job can be waiting behind another sandbox's. The
+ // running job comes first; at most one job per sandbox.
+ jobs: SandboxJob[]
}
export type SandboxesEvent = { type: "state"; state: SandboxesState }
diff --git a/packages/codev-sandbox/README.md b/packages/codev-sandbox/README.md
index 27654c3f2e..fd5dda41c6 100644
--- a/packages/codev-sandbox/README.md
+++ b/packages/codev-sandbox/README.md
@@ -1,27 +1,91 @@
# @codev/sandbox
-Platform-neutral core for the desktop sandbox: an isolated environment (VM or
-container) that runs the whole codev sidecar server so model-driven tools and
-skills execute away from the host.
+The desktop sandbox: an isolated environment (VM or container) that runs the
+whole codev sidecar server, so model-driven tools and skills execute away from
+the host. A ready sandbox appears in the desktop app as an ordinary server
+connection (`sandbox:`), under the **Work** side of the sidebar.
-Phase 0 of the sandbox plan — the pieces that need no platform backend:
+Runtime code sticks to Node APIs (no Bun-only calls) so it runs inside the
+Electron main process.
-- `provider.ts` — the `SandboxProvider` contract the per-platform backends
- implement (`vz` on macOS via Virtualization.framework, `wsl` on Windows via a
- managed distro, `bwrap` on Ubuntu).
-- `controller.ts` — lifecycle state machine generalized from the desktop WSL
- servers controller: runtime states, single-flight jobs, stale-start guards.
+## Platform core
+
+- `provider.ts` — the `SandboxProvider` contract every backend implements:
+ `availability` / `create` / `start` / `destroy` / `reset`, plus the optional
+ `applyMounts` hot path. Also the persisted `SandboxConfig` shape (project
+ dir, extra mounts, per-sandbox cpu/memory limits) and its validation.
+- `controller.ts` — lifecycle state machine: runtime states, the job queue,
+ stale-start guards. Create, reset and destroy run **one at a time across all
+ sandboxes** — not for the sandboxes' sake, but because create and reset stage
+ images through one shared cache whose resumable-download bookkeeping assumes
+ a single writer. Jobs queue rather than supersede, so a job on one sandbox
+ can never abort another sandbox's half-downloaded image; only a second job
+ for the *same* sandbox supersedes the first.
- `policy.ts` — OS version gates: hard block below macOS 12, tiered
- warn-never-block on Windows.
+ warn-never-block on Windows, always-supported on Linux.
- `manifest.ts` / `image.ts` — pinned manifest parsing and content-addressed,
SHA-256-verified staging of guest images and the mirrored WSL installer.
+ Downloads resume across retries and across calls (images are hundreds of MB
+ and storage hosts cut long transfers), and report progress for the UI.
- `broker.ts` — the sandbox's only egress path: a host-side proxy that forwards
- exclusively to configured upstreams (default: the LLM gateway). Guests have
- no network device; there is no full-outbound mode.
-- `boot.ts` — the guest boot contract shared with the WSL sidecar (credentials,
+ exclusively to configured upstreams (default: the LLM gateway), authenticated
+ with a per-start shared secret. Guests have no network device; there is no
+ full-outbound mode. Extra upstreams are an admin-editable allowlist of named
+ rules, never a general proxy.
+- `boot.ts` — the guest boot contract shared by every backend (credentials,
env, serve args, health polling).
+- `mounts.ts` — guest paths for extra host folders (`/mnt/` on vz/bwrap,
+ the wslpath translation on WSL) and the structural validation shared with the
+ desktop IPC handler.
+- `socket.ts` / `const.ts` — AF_UNIX-safe socket dirs, and the pinned storage
+ URLs for images and the mirrored WSL installer.
+
+## Backends
+
+### macOS — `vz/` + `native/vzhelper`
+
+- `native/vzhelper` — `codev-vmm`, a small Swift helper (SwiftPM, macOS 12+)
+ that boots one Linux VM via Virtualization.framework: VZLinuxBootLoader
+ (kernel + optional initrd + virtio disk), virtio-fs shares, guest serial
+ console on stderr, and **no network device** — the guest's only egress is
+ vsock, which the helper bridges to host unix sockets (host-to-guest for the
+ server path, guest-to-host for the broker path). Build with
+ `bun run build:vzhelper` (`--universal` for arm64+x86_64); dev builds are
+ ad-hoc signed with the `com.apple.security.virtualization` entitlement,
+ release packaging re-signs with the app identity.
+- `vz/protocol.ts` — the helper wire protocol: VmConfig JSON in,
+ newline-delimited state/error events out.
+- `vz/provider.ts` — availability gates on macOS 12+ and helper presence;
+ `create` unpacks the vz image tarball (`vmlinux` + `initrd` + `rootfs.img`)
+ and clones the instance disk copy-on-write (APFS clonefile); `start` writes
+ the guest `boot.json` and `mounts.json` (credentials, serve args, extra
+ mounts, shared read-only via the `config` virtio-fs tag), waits for the
+ helper's `running` event, then reuses the TCP→unix forwarder and health gate;
+ `reset` re-clones the pristine disk, re-provisioning first when the manifest
+ has moved on — which makes reset the image-upgrade path.
+
+### Windows — `wsl/`
+
+- `wsl/provider.ts` — a managed distro per sandbox (`wsl --import` of the same
+ rootfs tarball bubblewrap uses; WSL brings its own kernel). The host bundle
+ is bind-mounted in, so version parity with the app is automatic. Every
+ `wsl.exe` call is bounded by a timeout, with longer budgets for import and
+ unregister. This is the one backend that implements `applyMounts`: the
+ distro's mount namespace is shared and automounted drives need no guest work,
+ so mounts apply without a restart.
+- `wsl/firewall.ts` — the egress firewall. WSL2 distros share one utility VM,
+ one network namespace and one IP, so the guest is not NIC-less and host-side
+ Hyper-V rules cannot target a single distro. Enforcement is instead nftables
+ inside the shared guest kernel, matching a cgroup v1 `net_cls` classid the
+ boot script gives this sandbox's process tree: loopback and the host broker
+ endpoint are allowed, everything else is rejected. Fail-closed — a guest that
+ cannot set the rules up refuses to start rather than run open. The file
+ documents the residual shared-loopback exposure.
+- `wsl/cli.ts` — `wsl.exe` invocation, distro list parsing, and loopback port
+ reservation (all WSL2 distros share one loopback, so both the server port and
+ the guest broker stub port are reserved per sandbox rather than fixed).
-Phase 1 adds the first platform backend, `bwrap/` — the Ubuntu provider:
+### Linux — `bwrap/`
- `bwrap/args.ts` — the bubblewrap invocation: unshared user/pid/net/uts/ipc
namespaces (loopback-only networking), read-only rootfs binds with merged-usr
@@ -29,45 +93,22 @@ Phase 1 adds the first platform backend, `bwrap/` — the Ubuntu provider:
`/opt/codev`, per-sandbox home, cleared env.
- `bwrap/bridge.ts` — how TCP crosses the guest's network namespace: unix
sockets in the shared `/run/codev` dir, with a host-side TCP→unix forwarder
- and a guest-side node forward script for the server and broker paths.
+ (also used by the vz backend) and a guest-side node forward script for the
+ server and broker paths.
- `bwrap/rootfs.ts` — image unpack (tar) and rootfs layout probing.
-- `bwrap/provider.ts` — the `SandboxProvider`: availability probe (detects the
- Ubuntu 23.10+ AppArmor userns restriction), create/start/destroy/reset,
- health-gated startup with stderr capture.
+- `bwrap/provider.ts` — availability probe (detects the Ubuntu 23.10+ AppArmor
+ userns restriction), create/start/destroy/reset, health-gated startup with
+ stderr capture.
`bwrap.integration.test.ts` exercises real bubblewrap and auto-skips where
-bwrap can't run (macOS, restricted CI runners). Packaging note for later
-phases: the .deb should ship an AppArmor profile permitting unprivileged user
-namespaces on Ubuntu 24.04+.
+bwrap can't run (macOS, restricted CI runners). Packaging note: the .deb should
+ship an AppArmor profile permitting unprivileged user namespaces on Ubuntu
+24.04+.
-Phase 2 adds the macOS backend, `vz/` + `native/vzhelper`:
+## Image pipeline
-- `native/vzhelper` — `codev-vmm`, a small Swift helper (SwiftPM, macOS 12+)
- that boots one Linux VM via Virtualization.framework: VZLinuxBootLoader
- (kernel + initrd + virtio disk), virtio-fs shares, guest serial console on
- stderr, and **no network device** — the guest's only egress is vsock, which
- the helper bridges to host unix sockets (host-to-guest for the server path,
- guest-to-host for the broker path). Build with `bun run build:vzhelper`
- (`--universal` for arm64+x86_64); dev builds are ad-hoc signed with the
- `com.apple.security.virtualization` entitlement, release packaging re-signs
- with the app identity.
-- `vz/protocol.ts` — the helper wire protocol: VmConfig JSON in,
- newline-delimited state/error events out.
-- `vz/provider.ts` — the `SandboxProvider`: availability gates on macOS 12+
- (hard block per plan) and helper presence; `create` unpacks the vz image
- tarball (`vmlinux` + `initrd` + `rootfs.img`) and clones the instance disk
- copy-on-write (APFS clonefile); `start` writes the guest `boot.json`
- (credentials + serve args, shared read-only via the `config` virtio-fs tag),
- waits for the helper's `running` event, then reuses the Phase 1 TCP→unix
- forwarder and health gate; `reset` re-clones the pristine disk.
-
-Guest image contract for vz (image pipeline): the init must mount the
-`workspace`/`bundle`/`config` virtio-fs tags, read `/…/config/boot.json`, run
-the launcher with its env, and bridge vsock port 4096 to the server's TCP port
-and TCP port 8788 to vsock (e.g. via socat) for the broker.
-
-The image pipeline lives in `image/` and builds every backend's guest from
-one Debian rootfs, fully containerized (any machine with Docker):
+`image/` builds every backend's guest from one Debian rootfs, fully
+containerized (any machine with Docker):
- `image/Dockerfile` — base toolchain (node 22, python3 + pip/venv,
build-essential, git, ripgrep/jq/sqlite3/curl/unzip, socat, iproute2) plus
@@ -76,14 +117,15 @@ one Debian rootfs, fully containerized (any machine with Docker):
Debian, .NET 8 SDK at `/opt/dotnet`, the pinned Python package set, the
bundle's pure-JS node-module trees on `NODE_PATH`, and the office skills at
`/opt/codev-skills` with an `/etc/skel` codev.json pointing at them. Fresh
- sandbox homes get skel (vz at image build; bwrap seeded by the boot
- script), and images declare extra env in `/etc/codev-guest.env`.
-- `image/guest/codev-init` — the vz guest's PID 1: mounts the virtio-fs tags,
- runs the socat vsock bridges (4096 server / 8788 broker, host CID 2), and
- runs the launcher per `boot.json`; powers off (busybox) when it dies.
-- `image/guest/server-launch.mjs` — the boot entrypoint every backend runs.
- The bundle's `node.js` is a library module (exports `Server`), not a CLI, so
- the launcher imports it and calls `Server.listen({...})` with the env-provided
+ sandbox homes get skel (vz at image build; bwrap seeded by the boot script),
+ and images declare extra env in `/etc/codev-guest.env`.
+- `image/guest/codev-init` — the vz guest's PID 1: mounts the virtio-fs tags
+ (including the extra mounts listed in `mounts.json`), runs the socat vsock
+ bridges (4096 server / 8788 broker, host CID 2), and runs the launcher per
+ `boot.json`; powers off (busybox) when it dies.
+- `image/guest/server-launch.mjs` — the boot entrypoint every backend runs. The
+ bundle's `node.js` is a library module (exports `Server`), not a CLI, so the
+ launcher imports it and calls `Server.listen({...})` with the env-provided
credentials/host/port — exactly what the desktop sidecar does. Baked at
`/opt/codev-guest/server-launch.mjs`; `/opt/node_modules` holds the two
externalized deps (`jsonc-parser`, `@lydell/node-pty`) so the host bundle
@@ -93,18 +135,42 @@ one Debian rootfs, fully containerized (any machine with Docker):
drivers. Cached by Docker layers after the first build.
- `script/build-image.ts` — orchestrates buildx → rootfs tar → ext4 disk
(`mkfs.ext4 -d`, no root needed) → kernel → `vz-.tar.gz` /
- `bwrap-.tar.gz` → `manifest.json` (validated against
- `src/manifest.ts` before writing). `--office-bundle` stages the
- arch-independent parts of codev-office-ubuntu.zip; binaries are fetched
- arch-correct inside the build. Build each arch on a native host — x64 under
- emulation works but is slow.
+ `bwrap-.tar.gz` → `manifest.json` (validated against `src/manifest.ts`
+ before writing). `--office-bundle` stages the arch-independent parts of
+ codev-office-ubuntu.zip; binaries are fetched arch-correct inside the build.
+ Build each arch on a native host — x64 under emulation works but is slow.
- `script/publish-image.ts` — uploads artifacts first, `manifest.json` last
- (clients never see a version whose artifacts aren't in place), via `mc`
- when `CODEV_STORAGE_TARGET` is set, otherwise prints the commands.
-- `script/e2e-vz.ts` — boots a locally built image through `codev-vmm` and
- waits for the authenticated health check: the full-stack smoke test.
-
-Still to come: the Windows managed-WSL provider (consumes the same rootfs tar
-via `wsl --import`), the desktop main-process wiring, and the `packages/app`
-UI. Runtime code sticks to Node APIs (no Bun-only calls) so it can run inside
-the Electron main process.
+ (clients never see a version whose artifacts aren't in place), via `mc` when
+ `CODEV_STORAGE_TARGET` is set, otherwise prints the commands. The destination
+ key is derived from the same constant clients read, so a prefix move cannot
+ update one side only.
+- `script/e2e-vz.ts` / `script/e2e-wsl.ts` — boot a locally built image through
+ the real backend and wait for the authenticated health check: the full-stack
+ smoke tests.
+
+The guest image contract for vz: init must mount the `workspace` / `bundle` /
+`config` virtio-fs tags, read `/…/config/boot.json`, run the launcher with its
+env, and bridge vsock port 4096 to the server's TCP port and TCP port 8788 to
+vsock (e.g. via socat) for the broker.
+
+## Where the rest lives
+
+This package is platform code only; the desktop app owns everything user-facing.
+
+- `packages/desktop/src/main/sandbox/` — `controller.ts` (picks the platform
+ provider, persists configs), `ipc.ts` (the `sandbox-*` channels), `egress.ts`
+ (broker routes, gateway credentials, the injected guest config), `bundle.ts`
+ (the mounted server bundle).
+- `packages/app/src/sandbox/` — the UI: add dialog, mounts dialog, settings
+ rows, and the per-mount project seeding.
+- `packages/desktop/src/renderer/sandbox/` — ready sandboxes as server
+ connections.
+
+## Known gaps
+
+- **No stop.** `initialize()` starts every persisted sandbox at app launch and
+ the UI offers no way to shut one down short of deleting it, so N sandboxes
+ means N guests running (vz defaults: 4 cpus / 4 GiB each).
+- **Image upgrades are manual.** Reset re-provisions from the current manifest,
+ but nothing surfaces that a newer image exists, so guests stay stale until
+ the user happens to reset.
diff --git a/packages/codev-sandbox/src/controller.test.ts b/packages/codev-sandbox/src/controller.test.ts
index 7426cb7bd3..5987bbc0ff 100644
--- a/packages/codev-sandbox/src/controller.test.ts
+++ b/packages/codev-sandbox/src/controller.test.ts
@@ -39,6 +39,12 @@ function fakeProvider(overrides?: Partial): SandboxProvider {
}
const config: SandboxConfig = { id: "sandbox:default", name: "default", backend: "bwrap", projectDir: "/work" }
+const other: SandboxConfig = { id: "sandbox:other", name: "other", backend: "bwrap", projectDir: "/other" }
+const third: SandboxConfig = { id: "sandbox:third", name: "third", backend: "bwrap", projectDir: "/third" }
+
+// Lets every queued continuation run; the job queue hands off through
+// promises, so a job's turn starts a microtask after the one ahead of it ends.
+const tick = () => new Promise((resolve) => setTimeout(resolve, 0))
function runtimeOf(controller: SandboxController, id: string) {
return controller.getState().sandboxes.find((item) => item.config.id === id)?.runtime
@@ -78,7 +84,7 @@ test("adds a sandbox, persists it, and reports the running server", async () =>
password: "secret",
mounts: [],
})
- expect(controller.getState().job).toBe(null)
+ expect(controller.getState().jobs).toEqual([])
})
test("create reports image download progress on the job", async () => {
@@ -98,11 +104,11 @@ test("create reports image download progress on the job", async () => {
persisted = sandboxes
},
})
- controller.subscribe((event) => seen.push(event.state.job?.progress?.bytes))
+ controller.subscribe((event) => seen.push(event.state.jobs[0]?.progress?.bytes))
await controller.addSandbox(config)
expect(seen).toContain(10)
expect(seen).toContain(50)
- expect(controller.getState().job).toBe(null)
+ expect(controller.getState().jobs).toEqual([])
})
test("cancelling a create aborts the download and persists nothing", async () => {
@@ -135,7 +141,7 @@ test("cancelling a create aborts the download and persists nothing", async () =>
expect(await adding).toBeUndefined()
expect(persisted).toEqual([])
expect(controller.getState().sandboxes).toEqual([])
- expect(controller.getState().job).toBe(null)
+ expect(controller.getState().jobs).toEqual([])
expect(started).toBe(0)
})
@@ -154,11 +160,134 @@ test("cancelJob ignores a job for a different sandbox", async () => {
writeSandboxes: () => undefined,
})
const adding = controller.addSandbox(config)
+ await tick()
controller.cancelJob("sandbox:other")
resolveCreate?.()
expect(await adding).toEqual(config)
})
+// The bug this queue exists for: jobs used to share one abort controller, so
+// touching a second sandbox cancelled the first sandbox's image download —
+// silently, since a cancelled create resolves undefined.
+test("a job on one sandbox neither aborts nor overtakes another sandbox's job", async () => {
+ const order: string[] = []
+ let createAborted = false
+ let resolveCreate: (() => void) | undefined
+ const controller = createSandboxController({
+ providers: {
+ bwrap: fakeProvider({
+ create: (_config, opts) =>
+ new Promise((resolve) => {
+ opts?.signal?.addEventListener("abort", () => {
+ createAborted = true
+ })
+ resolveCreate = () => {
+ order.push("create")
+ resolve(undefined)
+ }
+ }),
+ reset: async (target) => {
+ order.push(`reset:${target.name}`)
+ },
+ }),
+ },
+ readSandboxes: () => [other],
+ writeSandboxes: () => undefined,
+ })
+ await controller.initialize()
+ const adding = controller.addSandbox(config)
+ await tick()
+ const resetting = controller.resetSandbox(other.id)
+ await tick()
+ expect(createAborted).toBe(false)
+ expect(order).toEqual([])
+ expect(controller.getState().jobs.map((job) => [job.id, job.status])).toEqual([
+ [config.id, "running"],
+ [other.id, "queued"],
+ ])
+ resolveCreate?.()
+ expect(await adding).toEqual(config)
+ await resetting
+ expect(order).toEqual(["create", "reset:other"])
+ expect(createAborted).toBe(false)
+ expect(controller.getState().jobs).toEqual([])
+})
+
+test("cancelling a queued job returns at once and never starts it", async () => {
+ const order: string[] = []
+ let resolveCreate: (() => void) | undefined
+ const controller = createSandboxController({
+ providers: {
+ bwrap: fakeProvider({
+ create: () =>
+ new Promise((resolve) => {
+ resolveCreate = () => {
+ order.push("create")
+ resolve(undefined)
+ }
+ }),
+ reset: async (target) => {
+ order.push(`reset:${target.name}`)
+ },
+ }),
+ },
+ readSandboxes: () => [other, third],
+ writeSandboxes: () => undefined,
+ })
+ await controller.initialize()
+ const adding = controller.addSandbox(config)
+ await tick()
+ const resetting = controller.resetSandbox(other.id)
+ const resettingThird = controller.resetSandbox(third.id)
+ await tick()
+ controller.cancelJob(other.id)
+ // Resolves while the create is still running: the dialog that asked for it
+ // must not sit on a spinner until an unrelated download finishes.
+ await resetting
+ await tick()
+ // ...and the job behind the cancelled one still waits its turn.
+ expect(order).toEqual([])
+ expect(controller.getState().jobs.map((job) => job.id)).toEqual([config.id, third.id])
+ resolveCreate?.()
+ await adding
+ await resettingThird
+ expect(order).toEqual(["create", "reset:third"])
+})
+
+test("a second job for the same sandbox supersedes the first", async () => {
+ const resets: (() => void)[] = []
+ let aborts = 0
+ const controller = createSandboxController({
+ providers: {
+ bwrap: fakeProvider({
+ reset: (_target, opts) =>
+ new Promise((resolve, reject) => {
+ opts?.signal?.addEventListener("abort", () => {
+ aborts++
+ const error = new Error("aborted")
+ error.name = "AbortError"
+ reject(error)
+ })
+ resets.push(() => resolve(undefined))
+ }),
+ }),
+ },
+ readSandboxes: () => [config],
+ writeSandboxes: () => undefined,
+ })
+ await controller.initialize()
+ const first = controller.resetSandbox(config.id)
+ await tick()
+ const second = controller.resetSandbox(config.id)
+ await tick()
+ expect(aborts).toBe(1)
+ expect(controller.getState().jobs.map((job) => job.id)).toEqual([config.id])
+ await first
+ resets.at(-1)?.()
+ await second
+ expect(controller.getState().jobs).toEqual([])
+})
+
test("rejects adding the same sandbox twice", async () => {
let persisted: SandboxConfig[] = [config]
const controller = createSandboxController({
@@ -429,6 +558,7 @@ test("updating mounts is rejected while the sandbox has a job in flight", async
await waitForRuntimeKind(controller, config.id, "ready")
const resetting = controller.resetSandbox(config.id)
expect(controller.updateSandboxMounts(config.id, [{ path: "/extra" }])).rejects.toThrow("operation in progress")
+ await tick()
resolveReset?.()
await resetting
expect(persisted).toEqual([config])
diff --git a/packages/codev-sandbox/src/controller.ts b/packages/codev-sandbox/src/controller.ts
index 9ee50a950e..7880514638 100644
--- a/packages/codev-sandbox/src/controller.ts
+++ b/packages/codev-sandbox/src/controller.ts
@@ -12,13 +12,18 @@ import type {
export type SandboxJob = {
kind: "create" | "destroy" | "reset"
id: string
+ // When the job was admitted to the queue, which is not when it started
+ // running — status says which of those has happened.
startedAt: number
+ status: "running" | "queued"
progress?: SandboxProgress
}
export type SandboxesState = {
sandboxes: SandboxItem[]
- job: SandboxJob | null
+ // The running job first, then the jobs waiting behind it. At most one job
+ // per sandbox: a second one supersedes the first (see runJob).
+ jobs: SandboxJob[]
}
export type SandboxesEvent = { type: "state"; state: SandboxesState }
@@ -41,11 +46,10 @@ export type SandboxController = ReturnType
// to any SandboxProvider: same runtime states, single-flight jobs, and
// stale-start-attempt guards, with WSL-specific probes left to providers.
export function createSandboxController(options: SandboxControllerOptions) {
- let state: SandboxesState = { sandboxes: [], job: null }
+ let state: SandboxesState = { sandboxes: [], jobs: [] }
const listeners = new Set<(event: SandboxesEvent) => void>()
const running = new Map()
const startAttempts = new Map()
- let jobAbort: AbortController | undefined
const logger = options.logger
const emit = () => {
@@ -69,41 +73,60 @@ export function createSandboxController(options: SandboxControllerOptions) {
return provider
}
- const beginJob = (job: SandboxJob): AbortController => {
- jobAbort?.abort()
- const abort = new AbortController()
- jobAbort = abort
- setState({ job })
- return abort
- }
+ // Create, reset and destroy run one at a time. Not for the sandbox's sake —
+ // each one only ever touches its own directories — but because create and
+ // reset stage guest images through one shared cache whose resumable-download
+ // bookkeeping assumes a single writer (image.ts removeStalePartials).
+ // Queueing rather than superseding is the whole point: a job on one sandbox
+ // must never abort another sandbox's half-downloaded image.
+ const queue: { job: SandboxJob; abort: AbortController }[] = []
+ // Resolves when the job currently at the back of the queue is done, so each
+ // new job waits for exactly the jobs admitted before it. Never rejects.
+ let tail: Promise = Promise.resolve()
+
+ const publishJobs = () => setState({ jobs: queue.map((entry) => entry.job) })
- const endJob = (abort: AbortController) => {
- if (jobAbort !== abort) return
- jobAbort = undefined
- setState({ job: null })
+ const patchJob = (entry: (typeof queue)[number], patch: Partial) => {
+ if (!queue.includes(entry)) return
+ entry.job = { ...entry.job, ...patch }
+ publishJobs()
}
const runJob = async (
- job: SandboxJob,
+ kind: SandboxJob["kind"],
+ id: string,
runner: (abort: AbortController, report: (progress: SandboxProgress) => void) => Promise,
) => {
- const abort = beginJob(job)
- const report = (progress: SandboxProgress) => {
- if (jobAbort !== abort) return
- setState({ job: { ...job, progress } })
- }
+ // A second job for the same sandbox supersedes the first: the caller asked
+ // for the newer outcome and both would work on the same directories.
+ queue.find((entry) => entry.job.id === id)?.abort.abort()
+ const job: SandboxJob = { kind, id, startedAt: Date.now(), status: "queued" }
+ const entry = { job, abort: new AbortController() }
+ queue.push(entry)
+ publishJobs()
+ const ahead = tail
+ let release: () => void = () => {}
+ tail = new Promise((resolve) => {
+ release = resolve
+ })
try {
- const value = await runner(abort, report)
- endJob(abort)
- return value
+ // Cancelling while queued has to return without waiting out the job
+ // ahead — the dialog that asked for it is sitting on this promise.
+ await Promise.race([ahead, aborted(entry.abort.signal)])
+ if (entry.abort.signal.aborted) return undefined
+ patchJob(entry, { status: "running" })
+ return await runner(entry.abort, (progress) => patchJob(entry, { progress }))
} catch (error) {
- if (error instanceof Error && error.name === "AbortError") {
- endJob(abort)
- return undefined
- }
- const err = error instanceof Error ? error : new Error(String(error))
- endJob(abort)
- throw err
+ if (error instanceof Error && error.name === "AbortError") return undefined
+ throw error instanceof Error ? error : new Error(String(error))
+ } finally {
+ const index = queue.indexOf(entry)
+ if (index >= 0) queue.splice(index, 1)
+ publishJobs()
+ // Released only once everything admitted earlier has finished: a job
+ // cancelled while it was still waiting must not let the one behind it
+ // jump ahead of the job that is actually running.
+ void ahead.then(release)
}
}
@@ -198,7 +221,7 @@ export function createSandboxController(options: SandboxControllerOptions) {
throw new Error(`${config.id} is already added`)
}
const provider = requireProvider(config.backend)
- const created = await runJob({ kind: "create", id: config.id, startedAt: Date.now() }, async (abort, report) => {
+ const created = await runJob("create", config.id, async (abort, report) => {
await provider.create(config, { signal: abort.signal, onProgress: report })
return true
})
@@ -211,11 +234,10 @@ export function createSandboxController(options: SandboxControllerOptions) {
return config
},
- // Aborts the in-flight job for this sandbox (the dialog cancels a create
- // mid-download); a mismatched or absent job is a no-op.
+ // Aborts this sandbox's job, running or still queued (the dialog cancels a
+ // create mid-download); a sandbox with no job is a no-op.
cancelJob(id: string) {
- if (state.job?.id !== id) return
- jobAbort?.abort()
+ queue.find((entry) => entry.job.id === id)?.abort.abort()
},
async removeSandbox(id: string) {
@@ -223,7 +245,7 @@ export function createSandboxController(options: SandboxControllerOptions) {
if (!item) return
invalidateStartAttempt(id)
stopSandboxInternal(id)
- await runJob({ kind: "destroy", id, startedAt: Date.now() }, async (abort) => {
+ await runJob("destroy", id, async (abort) => {
await requireProvider(item.config.backend).destroy(item.config, { signal: abort.signal })
})
options.writeSandboxes(options.readSandboxes().filter((config) => config.id !== id))
@@ -236,7 +258,7 @@ export function createSandboxController(options: SandboxControllerOptions) {
invalidateStartAttempt(id)
stopSandboxInternal(id)
setRuntime(id, { kind: "stopped" })
- const done = await runJob({ kind: "reset", id, startedAt: Date.now() }, async (abort, report) => {
+ const done = await runJob("reset", id, async (abort, report) => {
await requireProvider(item.config.backend).reset(item.config, { signal: abort.signal, onProgress: report })
return true
})
@@ -252,7 +274,8 @@ export function createSandboxController(options: SandboxControllerOptions) {
async updateSandboxMounts(id: string, mounts: SandboxMount[]) {
const item = state.sandboxes.find((x) => x.config.id === id)
if (!item) throw new Error(`no sandbox with id ${id}`)
- if (state.job?.id === id) throw new Error("the sandbox has an operation in progress; wait for it to finish")
+ if (queue.some((entry) => entry.job.id === id))
+ throw new Error("the sandbox has an operation in progress; wait for it to finish")
const provider = requireProvider(item.config.backend)
const config = { ...item.config, mounts }
// The persisted store and state.sandboxes[].config are separate; both
@@ -299,6 +322,13 @@ export function createSandboxController(options: SandboxControllerOptions) {
}
}
+function aborted(signal: AbortSignal) {
+ return new Promise((resolve) => {
+ if (signal.aborted) return resolve()
+ signal.addEventListener("abort", () => resolve(), { once: true })
+ })
+}
+
function exitFailure(code: number | null, signal: NodeJS.Signals | null) {
return `sandbox server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})`
}
From 7e0901701b6bc196e6e1853862232d234af298d2 Mon Sep 17 00:00:00 2001
From: minhn4
Date: Sat, 15 Aug 2026 10:57:27 +0700
Subject: [PATCH 2/2] feat(sandbox): stop a sandbox and keep it stopped
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A sandbox ran from app launch until it was deleted: initialize() started every
persisted sandbox and the UI offered no way to shut one down, so three
sandboxes meant three guests holding memory and CPU regardless of whether any
work was happening in them.
Adds stopSandbox: it cancels a start already in flight (the stale-attempt guard
makes the pending start discard its instance), stops the running guest, and
records `stopped` on the persisted config. initialize() skips those, so the
decision survives a relaunch — a stop that a restart undoes would not address
the problem. Only an explicit start clears the flag; the internal restarts
(initialize, reset, a mount change) leave the user's intent alone, and
resetting a stopped sandbox re-provisions it without bringing it up.
Stop is offered while a sandbox is still starting as well as when it is ready:
a start hanging on a slow image or a wedged VM is exactly when the user wants
out of it.
The Work side's empty state gets a stopped variant. It told the user "it
appears here once it is ready", which is true of a sandbox that is starting or
has failed but a false promise for one that is stopped and will never come up
on its own.
Co-Authored-By: Claude Opus 5 (1M context)
---
CHANGELOG.md | 9 ++
DIVERGENCES.md | 4 +-
packages/app/src/i18n/en.ts | 2 +
packages/app/src/i18n/es.ts | 2 +
packages/app/src/sandbox/settings.tsx | 11 ++
packages/app/src/sandbox/types.ts | 5 +
packages/app/src/workspace/tabs.tsx | 23 ++--
packages/codev-sandbox/README.md | 8 +-
packages/codev-sandbox/src/controller.test.ts | 104 ++++++++++++++++++
packages/codev-sandbox/src/controller.ts | 57 ++++++++--
packages/codev-sandbox/src/provider.ts | 4 +
.../desktop/src/main/sandbox/controller.ts | 2 +
packages/desktop/src/main/sandbox/ipc.ts | 3 +
packages/desktop/src/preload/index.ts | 1 +
14 files changed, 208 insertions(+), 27 deletions(-)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 957a122964..ac6fd259a3 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,15 @@ file is the human-readable summary of what changed and why.
### Desktop / Web UI
+- **Sandboxes can be stopped** — a sandbox ran from the moment the app started
+ until it was deleted, and every sandbox on the machine booted at launch, so
+ three sandboxes meant three virtual machines holding memory and CPU whether
+ or not any work was happening in them. Each sandbox now has a **Stop** entry
+ in its menu (Settings → Servers), which shuts its environment down; files on
+ its disk are kept, and running terminals and in-flight work stop. A stopped
+ sandbox stays stopped when the app is relaunched, so a machine only runs the
+ sandboxes you actually want running. **Start** in the same menu brings it
+ back, and resetting a stopped sandbox rebuilds it without starting it.
- **Working on one sandbox no longer cancels another one's work** — creating,
resetting, and deleting a sandbox all ran as a single operation with no
regard for which sandbox they belonged to, so starting one cancelled
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index ce019aa2f8..a065f83149 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -197,11 +197,11 @@ from the squash message first, or use a plain merge.
| 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. |
| Async settings stores: `packages/desktop/src/main/{store,index}.ts`, `packages/desktop/package.json` | `store.ts` no longer uses electron-store: `getStore` now returns the fork-only `store-file.ts` store — in-memory state, flushed write-behind through `store-writer.ts`'s serialized latest-wins async atomic writer (temp → fsync → rename via `fs.promises`, so the fsync runs on the libuv threadpool). Adds `flushStoreWrites(timeoutMs)`; `removeStoreFile`/`removeStoreFileIfEmpty` discard/flush the writer around the delete so a late flush cannot resurrect the file. `index.ts`: `stopSidecars` awaits `flushStoreWrites(1500)`. `package.json`: the `electron-store` dependency is removed. On-disk format is unchanged (flat keys, tab-indented JSON, no extension) — existing files load as-is; a corrupt file now starts empty with a logged error instead of throwing from every accessor. | conf v15's `store.set` is a synchronous fsync on the Electron main thread, and one of those parked the whole app in uninterruptible disk sleep (kernel journal stall + lost ext4 fast-commit wakeup, observed live during a sandbox mount-apply restart): event loop dead, IPC dead, quit impossible. conf also rereads the file on every access, which makes write-behind unsound with the stock class (a set on one key reads a stale file and drops a still-queued update), so the storage layer had to be replaced rather than patched. | Keep the fork store; take upstream's surrounding changes. If upstream adds electron-store options (schema, migrations, dot-notation) or new store call sites, extend `store-file.ts` to match — its API is the superset the desktop actually uses (`get/set/has/delete/clear/store/path`). Do not reintroduce a synchronous write path on the main thread. |
| 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; projectDir?; mounts? }` with key `sandbox:` (`projectDir` is the host folder mounted into the guest and `mounts` the extra host folders with their resolved guest paths — the guest only ever sees its own mount points, so this is the only way the app can name a sandbox's projects after the folders it works on); `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) and moves the `tabs.removeServer(key)` + `server.remove(key)` pair *above* both awaited platform removals; `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; `sandbox.add.projectDirHint`, the standing explanation above the folder field; the `sandbox.mounts.*` family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox; and `sandbox.job.queued`, shown while a sandbox's create/reset/destroy waits its turn behind another sandbox's job); 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. Removing a server destroys a disk and takes seconds, and the connection leaves the list the moment it finishes, so the tab cleanup has to happen before the await rather than after it. | 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. Keep the tab cleanup ahead of the platform removals in `handleRemove`; if upstream reorders it back, re-apply. |
+| 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; projectDir?; mounts? }` with key `sandbox:` (`projectDir` is the host folder mounted into the guest and `mounts` the extra host folders with their resolved guest paths — the guest only ever sees its own mount points, so this is the only way the app can name a sandbox's projects after the folders it works on); `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) and moves the `tabs.removeServer(key)` + `server.remove(key)` pair *above* both awaited platform removals; `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; `sandbox.add.projectDirHint`, the standing explanation above the folder field; the `sandbox.mounts.*` family for the manage-folders dialog that adds/removes extra mounts on an existing sandbox; `sandbox.job.queued`, shown while a sandbox's create/reset/destroy waits its turn behind another sandbox's job; and `sandbox.server.stop` for the menu entry that shuts a sandbox down until it is explicitly started again); 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. Removing a server destroys a disk and takes seconds, and the connection leaves the list the moment it finishes, so the tab cleanup has to happen before the await rather than after it. | 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. Keep the tab cleanup ahead of the platform removals in `handleRemove`; if upstream reorders it back, re-apply. |
| Desktop identity: `packages/desktop/src/main/{index,store}.ts`, `packages/desktop/electron-builder.config{,.test}.ts`, `packages/desktop/scripts/copy-metainfo.ts`, `packages/desktop/resources/linux/opencode-desktop.desktop` (deleted), `packages/app/src/pages/layout/{deep-links.ts,helpers.test.ts}`, `packages/codev-brand/test/brand.test.ts` | appId `ai.opencode.desktop{,.beta,.dev}` → `ai.codev.desktop{,.beta,.dev}` (both copies: `APP_IDS` in main and in the builder config, plus the unpackaged fallback); deep-link scheme `opencode://` → `codev://` in all three places that must agree — `protocols.schemes`, `app.setAsDefaultProtocolClient`, and the renderer's `parseUrl` — plus the `argv.startsWith` filter the second-instance handler uses; `rpm.packageName` `opencode`/`opencode-beta`/`opencode-dev` → `codev`/`codev-beta`/`codev-dev`; the hidden `opencode-desktop.desktop` legacy launcher and its `deb`/`rpm` fpm wiring deleted. `src/main/migrate.ts` keeps the old ids — they name the *Tauri* app's directories, not ours. | The appId is the whole desktop identity: `app.setPath("userData", join(appData, appId))`, so it also names the data directory, and it drives the macOS bundle id, the Windows AppUserModelId and NSIS keys, the Linux `.desktop` filename, `executableName` and `StartupWMClass`, and the AppStream metainfo id. Leaving it as opencode meant the fork's app identified itself as upstream to the OS. Renamed as a clean cut with no data migration and no back-compat shim, on the explicit basis that there is no installed base: existing profiles under `appData/ai.opencode.desktop*` are simply orphaned (settings, session history, sandboxes and the downloaded guest image), and a packaged update across the rename would install beside the old app rather than replace it. The legacy launcher went with it — it existed to rescue GNOME/KDE pins from a superseded packaging id, and its `Exec=/opt/OpenCode/...` no longer matched any install layout. | Keep our ids and scheme; take upstream's other config. Never rename the `TAURI_APP_IDS` in `migrate.ts` to match — a blind find-and-replace there points the Tauri migration at a directory that never existed. All three deep-link sites have to move together or links open nothing; `electron-builder.config.test.ts` covers the ids, the package names and the scheme per channel. |
-| Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code only where the platform has no sandbox support — deliberately not on an empty sandbox list, which is indistinguishable from one that has not loaded; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; i18n gains `workspace.{code,work,empty.none,empty.starting}`. | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code where the platform has no sandbox support, which is what keeps the web build coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. |
+| Code/Work workspace split: `packages/app/src/context/layout.tsx`, `packages/app/src/components/{app-sidebar,titlebar}.tsx`, `packages/app/src/i18n/{en,es}.ts` | The sidebar gains two tabs above Projects — **Code** (every non-sandbox server: the local sidecar, WSL, remote, SSH) and **Work** (the sandboxes) — backed by the fork-only `packages/app/src/workspace/{side.ts,tabs.tsx}`. `layout.tsx` stores `home.workspace` and `home.lastSession` (per side) and records the latter from an effect on `route()`; `app-sidebar.tsx` renders ``, swaps `global.servers.list()` for a side-filtered memo in all six places it indexed it, and adds an empty-state branch because a side can now have no servers at all; `titlebar.tsx` loses the Home button and its `home.toggle` command (`mod+b`) and filters the tab strip, its reorder and `tab.prev`/`tab.next` to the visible side; `effectiveSide` falls back to Code only where the platform has no sandbox support — deliberately not on an empty sandbox list, which is indistinguishable from one that has not loaded; `layout.tsx` also holds an effect keeping `home.selection.server` on the showing side, because a sandbox joins the server list only once it has finished starting, so the one-shot correction inside a switch cannot cover a sandbox created moments earlier; the fork-only `DialogAddSandbox` gains an `onCreated` callback so the Work gate switches on the create's own result rather than on the asynchronous sandbox list; `dialog-select-server.tsx` `handleRemove` calls `workspace.removedServer(key)` beside the tab cleanup, which steps back to Code when the sandbox being deleted is the last one; i18n gains `workspace.{code,work,empty.none,empty.starting,empty.stopped}` (the last for a Work side whose sandboxes are all stopped, which unlike starting or failed will never come up on its own). | Sandboxes were just another row in a list of servers, so the isolated environment and the local machine were one undifferentiated pile. Splitting them makes "am I running on my machine or in a sandbox?" a property of where you are rather than something to infer from a server name. The Home button went because it opened a draft rather than a home screen, which is the behaviour the tabs replace: a tab returns you to that side's last session, falling back to a draft and then to the home screen. All the logic lives in the fork-only module; the upstream files hold wiring only, and every section below the tabs is the same component reading a filtered list. | Keep the wiring and take upstream's surrounding changes. The six `sideServers()` call sites in `app-sidebar.tsx` were `global.servers.list()` — if upstream adds a seventh, filter it too or that branch will show sandboxes under Code. Read `useWorkspaceSide()` rather than `layout.home.workspace()`: it collapses to Code where the platform has no sandbox support, which is what keeps the web build coherent. The tab strip filter and `reorderWithinSide` go together — `tabs.reorder()` rejects a partial key list, so a filtered strip without the merge silently ignores drags. |
| `packages/app/src/context/global.tsx` | Two lines: imports `seedSandboxProjects` from the fork-only `packages/app/src/sandbox/projects.ts` and calls it with `server.list` + `ensureServerCtx`, beside the memo that already creates a ctx per connection. All the logic lives in the fork-only module. | A sandbox exposes exactly one directory (`/workspace` on vz/bwrap, the wslpath translation on WSL) and the guest registers it as its own project, but the app's per-server project list starts empty and nothing ever fills it. With no project, every "New session" entry point — `home-model.openNewSession`, the titlebar's `openNewTab` fallback chain — finds nothing for that server and falls back to another one, so the sandbox looks selected while the session runs on the local server. The seed reads `sync.data.path.directory`, which every server ctx already fetches, so it stays backend-agnostic. The same module also names that project after the sandbox's host folder (via `client.project.update`, the call behind rename-project), because the guest sees only its mount point and every sandbox would otherwise read "workspace" in the sidebar, tabs and palette. | Keep both lines; take upstream's other changes to the file. The call must stay inside the Global context init (it needs `ensureServerCtx`) — `SandboxesProvider` is mounted above `GlobalProvider` in `app.tsx`, so it cannot live there. |
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index 96a1ee7b1f..13cf45d6f8 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -378,10 +378,12 @@ export const dict = {
"workspace.work": "Work",
"workspace.empty.none": "No sandbox yet. Create one to work in an isolated environment.",
"workspace.empty.starting": "Your sandbox is not running yet. It appears here once it is ready.",
+ "workspace.empty.stopped": "Your sandbox is stopped. Start it again from Settings → Servers.",
"sandbox.server.add": "Add sandbox",
"sandbox.server.label": "Sandbox",
"sandbox.server.menu.label": "Sandbox",
"sandbox.server.start": "Start",
+ "sandbox.server.stop": "Stop",
"sandbox.server.reset": "Reset",
"sandbox.server.mounts": "Folders...",
"sandbox.status.starting": "Starting...",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index d875ce383e..4850bcaa41 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -369,10 +369,12 @@ export const dict = {
"workspace.work": "Trabajo",
"workspace.empty.none": "Aún no hay ningún sandbox. Crea uno para trabajar en un entorno aislado.",
"workspace.empty.starting": "Tu sandbox todavía no está en marcha. Aparecerá aquí cuando esté listo.",
+ "workspace.empty.stopped": "Tu sandbox está detenido. Vuelve a iniciarlo desde Ajustes → Servidores.",
"sandbox.server.add": "Añadir sandbox",
"sandbox.server.label": "Sandbox",
"sandbox.server.menu.label": "Sandbox",
"sandbox.server.start": "Iniciar",
+ "sandbox.server.stop": "Detener",
"sandbox.server.reset": "Restablecer",
"sandbox.server.mounts": "Carpetas...",
"sandbox.status.starting": "Iniciando...",
diff --git a/packages/app/src/sandbox/settings.tsx b/packages/app/src/sandbox/settings.tsx
index e31ecd2262..b17e6d1eb9 100644
--- a/packages/app/src/sandbox/settings.tsx
+++ b/packages/app/src/sandbox/settings.tsx
@@ -26,6 +26,12 @@ export function sandboxRuntimeRetryable(runtime: SandboxRuntime) {
return runtime.kind === "failed" || runtime.kind === "stopped"
}
+// Stopping is offered while it is still coming up too: a start that hangs on a
+// slow image or a wedged VM is exactly when the user wants out of it.
+export function sandboxRuntimeStoppable(runtime: SandboxRuntime) {
+ return runtime.kind === "ready" || runtime.kind === "starting"
+}
+
export function useFilteredSandboxes(filter: Accessor) {
const sandboxes = useSandboxes()
return createMemo(() => {
@@ -111,6 +117,11 @@ export function SandboxServerSettings(props: {
{language.t("sandbox.server.start")}
+
+ api && request.mutate(() => api.stop(key))}>
+ {language.t("sandbox.server.stop")}
+
+ api && request.mutate(() => api.reset(key))}>
{language.t("sandbox.server.reset")}
diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts
index 36250e5441..0ed6370be9 100644
--- a/packages/app/src/sandbox/types.ts
+++ b/packages/app/src/sandbox/types.ts
@@ -13,6 +13,9 @@ export type SandboxConfig = {
// Extra host folders shared into the guest alongside projectDir; absent
// means none. Objects so a readOnly flag can be added without migration.
mounts?: SandboxMount[]
+ // Set when the user stopped this sandbox, so it stays down across relaunches
+ // instead of booting with the rest.
+ stopped?: boolean
} & SandboxResources
export type SandboxMount = {
@@ -80,6 +83,8 @@ export type SandboxesPlatform = {
remove(id: string): Promise
reset(id: string): Promise
start(id: string): Promise
+ // Shuts the sandbox down until the user starts it again, across relaunches.
+ stop(id: string): Promise
// Replaces the extra-mount list. Applies hot on Windows (WSL); on macOS and
// Linux a running sandbox restarts to pick the new list up.
updateMounts(id: string, mounts: SandboxMount[]): Promise
diff --git a/packages/app/src/workspace/tabs.tsx b/packages/app/src/workspace/tabs.tsx
index 82e4b6588a..446db5a5dc 100644
--- a/packages/app/src/workspace/tabs.tsx
+++ b/packages/app/src/workspace/tabs.tsx
@@ -99,22 +99,27 @@ export function useWorkspaceSwitch() {
}
}
-// Reachable on Work while its only sandbox is still starting or has failed —
-// a sandbox joins the server list only once it is ready — and after the last
-// sandbox is removed. Code always has the local server, so it only shows here
-// if every server is somehow gone.
+// Reachable on Work while its only sandbox is still starting, has failed, or
+// was stopped — a sandbox joins the server list only once it is ready — and
+// after the last sandbox is removed. Code always has the local server, so it
+// only shows here if every server is somehow gone.
export function WorkspaceEmpty(props: { side: WorkspaceSide }) {
const language = useLanguage()
const dialog = useDialog()
const sandboxes = useSandboxes()
- const configured = () => sandboxes.state.data?.sandboxes.length ?? 0
+ const configured = () => sandboxes.state.data?.sandboxes ?? []
+ // Nothing here is coming up on its own once every sandbox is stopped, so
+ // saying "it appears here once it is ready" would be a promise we do not
+ // keep. One sandbox still starting is enough for the wait to be true.
+ const message = () => {
+ if (props.side !== "work" || !configured().length) return "workspace.empty.none"
+ return configured().every((item) => item.config.stopped) ? "workspace.empty.stopped" : "workspace.empty.starting"
+ }
return (