From 9a78f0b509dbfdcfd08c217b43915720bcc99d75 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 16 Aug 2026 22:25:18 +0700 Subject: [PATCH 1/3] fix(sandbox): keep the guest database off the host's branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A dev build takes its channel from `git branch --show-current`, and the server files its database as `opencode-.db` for any channel that is not a release one. The sandbox guest runs the host's bundle, so the guest was re-keyed to whatever branch the host repo had checked out: every branch switch pointed it at a fresh, empty database and every session in the sandbox vanished from the UI. Nothing was lost — one guest here had accumulated opencode-main.db, opencode-work-folder-tabs.db and a third — but nothing could be reached either. Guests now set CODEV_DISABLE_CHANNEL_DB=1, which the server already honours. Packaged builds already resolved to the same opencode.db, so this only changes what a dev build does. The trade is one guest database shared across branches: a branch that adds a migration migrates it for the others. Sessions written before this change are still in their per-branch files under ~/.local/share/codev in the guest. Co-Authored-By: Claude Opus 5 (1M context) --- packages/codev-sandbox/src/boot.test.ts | 4 ++++ packages/codev-sandbox/src/boot.ts | 15 +++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/packages/codev-sandbox/src/boot.test.ts b/packages/codev-sandbox/src/boot.test.ts index 834ccb6ac5..120648991c 100644 --- a/packages/codev-sandbox/src/boot.test.ts +++ b/packages/codev-sandbox/src/boot.test.ts @@ -12,6 +12,10 @@ test("boot env mirrors the WSL sidecar contract and carries the server address", CODEV_CLIENT: "desktop", CODEV_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", XDG_STATE_HOME: "/home/codev/.local/state", + // Without this the guest files its database under the host build's + // channel — a dev build's channel is the host repo's branch — so every + // branch switch hid the sandbox's sessions behind a fresh database. + CODEV_DISABLE_CHANNEL_DB: "1", }) }) diff --git a/packages/codev-sandbox/src/boot.ts b/packages/codev-sandbox/src/boot.ts index 65d9c55e64..c73b925aa6 100644 --- a/packages/codev-sandbox/src/boot.ts +++ b/packages/codev-sandbox/src/boot.ts @@ -26,6 +26,21 @@ export function sandboxBootEnv( CODEV_CLIENT: "desktop", CODEV_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", XDG_STATE_HOME: `${guestHome}/.local/state`, + // One database for the sandbox, whatever the host is doing. A dev build + // takes its channel from `git branch --show-current` (packages/script), + // and the server files its database as `opencode-.db` for any + // channel that is not a release one — so the guest, which runs the host's + // bundle, quietly switched to a different database every time the host + // repo changed branch, and every session in the sandbox vanished from the + // UI. Nothing was lost, but nothing could be reached either. The guest is + // its own machine and has no business tracking the host's branch. + // + // Packaged builds already resolve to the same `opencode.db` (their channel + // is a release one), so this only changes what a dev build does. It also + // means one guest database shared across branches: a branch that adds a + // migration migrates it for the others, which is the trade upstream avoids + // on the host and which is worth making here. + CODEV_DISABLE_CHANNEL_DB: "1", } } From 3f877bc2618e107783ccf458a6d8d273fa0b976d Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 16 Aug 2026 22:25:44 +0700 Subject: [PATCH 2/3] fix(sandbox): commit guest writes to the disk, not the page cache MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sessions created in a sandbox disappeared when it stopped. Stopping one is a power cut — the vz helper calls VZVirtualMachine.stop(), which Apple documents as equivalent to pulling the power cable, and the guest gets no chance to sync — while the server's database runs WAL with synchronous=NORMAL, which is durable across a crashed process but not across a machine that goes away. Every commit still in the guest's page cache died with the VM, so a Stop/Start, a restart to apply a mount, or a force quit took the newest sessions with it. Measured on a real vz guest: a file written and left unsynced is gone after a plain Stop/Start, and so was a session created seconds earlier. `PRAGMA synchronous` now reads CODEV_SQLITE_SYNCHRONOUS, whitelisted because the value lands in a PRAGMA and defaulting to NORMAL so nothing changes for anything that does not set it. Sandbox guests set FULL. The knob has to live in core because the guest runs the host's own server bundle. After: a session created two seconds before `kill -9` of the whole app is still there on the next start. Co-Authored-By: Claude Opus 5 (1M context) --- DIVERGENCES.md | 3 ++- packages/codev-sandbox/README.md | 15 ++++++++++++++- packages/codev-sandbox/src/boot.test.ts | 3 +++ packages/codev-sandbox/src/boot.ts | 9 +++++++++ packages/core/src/database/database.ts | 13 ++++++++++++- packages/core/src/flag/flag.ts | 1 + 6 files changed, 41 insertions(+), 3 deletions(-) diff --git a/DIVERGENCES.md b/DIVERGENCES.md index a753805352..bbcfb31a18 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -209,6 +209,8 @@ from the squash message first, or use a plain merge. | 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. | | `packages/app/src/components/server/server-row-menu.tsx` | The trailing **Delete** item carries `style={{ "--menu-v2-fg": "var(--v2-state-fg-danger)" }}`. Nothing else changes — same `disabled={builtin}`, same `handleRemove(key)`, same position after the separator. | Delete rendered in the same colour as Edit and Set as default, so the one irreversible action in the menu carried no more weight than the rest. The session actions menu already colours its Delete this way; this brings the server menu in line. It has to be the CSS variable rather than a text utility: `menu-v2.css` colours the item **and** its `menu-v2-item-content` label span from `--menu-v2-fg`, so a class on the item never reaches the label. | Keep the style prop. On conflict take upstream's item and re-apply it. Do not swap it for a Tailwind text colour — that silently only recolours half the item. The disabled state dims by `opacity` alone, so the red survives it. | | 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` and, on the desktop variant, `openFilePathDialog` (a path-only file picker — the existing attachment picker reads the file's bytes, which is unusable for a multi-gigabyte guest image), implemented in `renderer/index.tsx` over the existing `open-file-picker` channel and releasing the read token immediately; `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; `sandbox.server.stop` for the menu entry that shuts a sandbox down until it is explicitly started again; the `sandbox.add.image*` / `sandbox.add.name*` / `sandbox.arch.*` families for the optional custom-image field and the sandbox-name checks; and `sandbox.add.resourcesHint` plus interpolated `sandbox.add.{cpus,memory,resourcesInvalid}`, which quote the vz defaults and ranges from the constants rather than restating them in prose); 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. | +| Sandbox guest durability: `packages/core/src/database/database.ts`, `packages/core/src/flag/flag.ts` | `PRAGMA synchronous` is no longer hardcoded to `NORMAL`: it reads `Flag.CODEV_SQLITE_SYNCHRONOUS`, validated against a whitelist (`OFF`/`NORMAL`/`FULL`/`EXTRA`) because the value lands in a PRAGMA, and falls back to `NORMAL` — so nothing changes for any process that does not set it. The sandbox sets it to `FULL` for guests (fork-only `packages/codev-sandbox/src/boot.ts`). | WAL + `synchronous = NORMAL` is durable across a crashed process but not across a machine that disappears, because those commits sit in the page cache until a checkpoint. A sandbox guest is exactly that machine: `VZVirtualMachine.stop()` is documented as pulling the power cable, and the guest gets no chance to sync — so stopping a sandbox, restarting one to apply a mount, or force quitting the app dropped every session written in the preceding seconds. Measured on a real vz guest: a file written and left unsynced is gone after a plain Stop/Start; with `FULL` a session created two seconds before `kill -9` of the whole app survives. The knob lives in core because the guest runs the host's own server bundle — there is no sandbox-only build to patch. | Keep the flag and the whitelist; take upstream's other pragmas. If upstream makes synchronous configurable its own way, drop ours for theirs. Never widen the whitelist to pass the value through unchecked. The default must stay `NORMAL` — raising it for every install would make the host pay for a guarantee only the sandbox needs. | +| `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for ``. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. | | 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. | @@ -224,7 +226,6 @@ from the squash message first, or use a plain merge. | Tabs that outlive a sandbox restart: `packages/app/src/context/{server,tabs}.tsx`, `packages/app/src/app.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/renderer/index.tsx` | `server.tsx` takes a `retainedServers` prop and exposes it as `retained` — servers that exist but are not connected; `tabs.tsx` reconciles both of its prune effects (open tabs and the closed-tab stack) against `list` **plus** `retained` through a shared `knownServers()`; `app.tsx` threads `retainedServers` from `AppInterface` into `ServerProvider`, and **both** server-scoped routes — `TargetServerRoute` and `ResolvedDraftRoute` — render the fork-only `packages/app/src/sandbox/server-away.tsx` (a `LoaderV2` over the copy, since the state always ends by itself) instead of mounting their providers when the route's server is retained but absent; i18n gains `session.error.disconnected{,.description}`, worded around "this tab" rather than "this session" because the draft route shares the screen; `retained` is read by three fork-only consumers besides the routes — `pages/home-model.tsx` keeps the sidebar's selection on an away server and renders its projects/pinned/recents empty rather than the current server's, and `workspace/tabs.tsx` keeps the tab strip's folder scope — so a restart no longer swaps the sidebar to the local machine and back; `renderer/index.tsx` passes `retainedSandboxServers(...)` (fork-only, in `renderer/sandbox/connections.ts`) — every sandbox that exists, whatever its runtime state. | A sandbox is published as a connection only while it is ready, so applying a mount — which restarts the guest — takes it out of `server.list` for a second or two. The tab pruner read that as "this server is gone" and deleted every tab belonging to it, discarding each tab's memory: the typed prompt, terminal and scroll state. Retention draws the line where the user does — the sandbox still exists, so its tabs do — and explicit removal is unaffected because `dialog-select-server` already calls `tabs.removeServer(key)` and the sandbox then leaves `retained` as well. The route guard is the other half: a retained server has no connection, and mounting `ServerSDKProvider`/`ServerSyncProvider` with `undefined` sends the session's requests to whichever server is selected instead, which answered "this session cannot be found" about a healthy session and bounced the route home through a burst of ~250 navigations. WSL servers are deliberately not retained — same latent problem, but the WSL vertical has no equivalent restart in normal use. | Keep all five edits together — retention without the route guards just moves the confusing state, and the guards without retention are unreachable. Both routes need the guard: they are the two places a tab can point at a server, and the draft one is worse without it — a draft resolves to whichever server is selected, so the prompt would be sent to the local machine rather than the sandbox the tab belongs to. Take upstream's other changes to these files. `retained` must stay a superset relationship with `list` (a server can be in both mid-transition); if upstream reworks the pruner, re-apply the union. The web build passes no `retainedServers`, so `retained` is empty there and every one of these paths collapses to upstream behaviour. | | `script/publish.ts` | The `if (Script.release)` block calling `packages/desktop/scripts/finalize-latest-{json,yml}.ts` removed, replaced by a comment. | Both finalizers existed to assemble update metadata into a GitHub release, which the fork no longer publishes to. electron-builder already writes a correct per-platform `latest*.yml` next to each installer, and the fork builds one arch per platform (macOS is universal — see the universal-builds row), so there is nothing left to merge. | Keep removed. If upstream adds further desktop release steps here, drop them unless they target the bucket. | | `packages/desktop/scripts/finalize-latest-json.ts`, `packages/desktop/scripts/finalize-latest-yml.ts` | Deleted. | Both `gh release upload` into a GitHub release the fork does not use. `finalize-latest-yml.ts` merged per-arch `latest*.yml` from an upstream build matrix the fork does not run; `finalize-latest-json.ts` additionally built a Tauri-style `latest.json` signed with `@tauri-apps/cli signer` that nothing in the Electron updater path ever reads, and had gone stale besides — it hardcoded `opencode-desktop-mac-{x64,arm64}.app.tar.gz`, an artifact name and format this fork never produces. Leaving them in place is a trap: they look like the release path and upload to the wrong place. | Keep deleted (`git rm` the upstream side of modify/delete conflicts). If a future build matrix produces two `latest.yml` files for one platform (Windows x64 + arm64), write a bucket-targeted merge step rather than restoring these. | -| `packages/desktop/src/renderer/index.tsx` | Desktop notification icon `https://opencode.ai/favicon-96x96-v3.png` → the relative `./favicon-96x96-v3.png`. The renderer's `publicDir` is `packages/app/public`, whose `favicon-96x96-v3.png` symlinks to the rebranded `packages/ui/src/assets/favicon/` mark, so the file is emitted next to `index.html` and resolves against the renderer origin (`oc://renderer/` packaged, the vite dev server otherwise) — the same asset and the same relative form `src/renderer/index.html` already uses for ``. | A desktop app should not reach out to a remote host to draw its own notification icon (it fails offline and leaks a request per notification), and the remote file is upstream-branded while the bundled one is the codev mark. | Keep our relative path. If upstream changes the URL, re-point it at the bundled asset rather than taking a new remote host. `packages/app/src/entry.tsx` and `pages/layout/helpers.ts` keep their remote `opencode.ai/favicon*` sources — same bug class, not yet fixed. | | `packages/desktop/scripts/prebuild.ts` | On macOS, after the node-sidecar build, runs `bun script/build-vzhelper.ts --universal` in `packages/codev-sandbox` so `dist/codev-vmm` exists (universal) before electron-builder copies it via `mac.extraResources`. | The sandbox vz backend spawns the helper from `process.resourcesPath/codev-vmm` in packaged builds; universal keeps one already-fat Mach-O valid for `--arm64`, `--x64`, and `--universal` packaging (an identical thin binary in both halves would trip `@electron/universal`'s identical-file check). | Keep the darwin-gated build line; take upstream's other steps. It must stay after nothing in particular but before packaging — prebuild is the hook `package:*` runs. | | `packages/desktop/scripts/predev.ts` | Runs `bun run install-electron` before the existing icon copy and node-sidecar build. | `bun dev:desktop` was dead on a fresh clone: Electron 42 removed the `postinstall` hook that downloaded the platform binary and exposes it as an `install-electron` bin the consumer must call (electron 41's manifest still had `"postinstall": "node install.js"`; 42.3.3's has no `scripts` field at all), so `bun install` leaves `node_modules/electron` with no `dist/` and `electron-vite dev` aborts with `Error: Electron uninstall` from `getElectronPath`. `predev` is the right hook rather than the root `postinstall`: only someone actually launching the desktop app pays the ~100 MB download, and `install.js` short-circuits once `dist/version` and `path.txt` match, so repeat runs cost nothing. Not added to `prebuild`/`package` — `electron-vite build` never spawns Electron, and `electron-builder` fetches its own copy through `@electron/get`. | Keep our installer line at the top; take upstream's other steps. Drop the line if upstream adds its own binary-install step or pins Electron back below 42. | | `packages/ui/src/assets/favicon/` (favicon-v3.svg, favicon-v3.ico, site.webmanifest, favicon-96x96-v3.png, apple-touch-icon-v3.png, web-app-manifest-192/512.png) | New codev mark: the blocky "C" in brand red `#ee0033` on a white tile, generated from the same geometry as the app icon by `packages/desktop/scripts/generate-icons.ts` (4x5 grid, one-unit stroke, unit = 120/1024, corner radius 229/1024); `favicon-v3.svg` is the hand-written 512-unit source of that geometry. The tab and `.ico` icons keep the rounded tile so they read as an app icon; `apple-touch-icon-v3.png` and the two maskable `web-app-manifest-*` icons are square and full-bleed, because iOS and PWA launchers apply their own mask and a pre-rounded icon leaves transparent corners that render black. Manifest name "CoDev Code"/"codev"; its `theme_color`/`background_color` stay `#080808` — those describe the PWA's chrome and splash, not the icon. **Was `#00cdcd` on a dark tile** until this row was rewritten: PR #10 pinned the favicon to the then-cyan TUI banner accent, that banner later moved to the navy/red wordmark, and the favicon was left behind as the only cyan in the tree — visibly so on desktop, where the dock icon was red while the window and notification icons were cyan. | Every surface that draws the mark should draw the same mark: the dock icon, the tab, notifications, the `codev --mini` badge and the landing page icon are now one geometry in one red. Cyan on `#131010` was the higher-contrast tile at 16px (~9.4:1 versus red's ~4.2:1) — a real cost, accepted so the brand reads consistently. | Keep our assets (binary: take ours wholesale). Regenerate with the script rather than hand-editing, and edit `icons/icon.svg` + `favicon-v3.svg` together if the geometry ever changes. | diff --git a/packages/codev-sandbox/README.md b/packages/codev-sandbox/README.md index b32f2808ba..0e0ab0b142 100644 --- a/packages/codev-sandbox/README.md +++ b/packages/codev-sandbox/README.md @@ -36,7 +36,13 @@ Electron main process. 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). + env, serve args, health polling). Two of those env entries exist because a + guest is not a normal machine: `CODEV_SQLITE_SYNCHRONOUS=FULL`, because + stopping a sandbox cuts its power and the server's default WAL + + `synchronous=NORMAL` only survives a crashed process, not a machine that + disappears; and `CODEV_DISABLE_CHANNEL_DB=1`, because a dev build takes its + channel from the host repo's current branch, which otherwise re-keyed the + guest's whole database every time the host changed branch. - `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. @@ -172,6 +178,13 @@ This package is platform code only; the desktop app owns everything user-facing. ## Known gaps +- **Stopping a sandbox is a power cut.** `native/vzhelper` calls + `VZVirtualMachine.stop()`, which Apple documents as pulling the power cable; + the guest never syncs. Durable SQLite commits cover the server's own data, + but anything else the guest wrote to its disk in the last seconds is still + lost. A graceful path — the helper asking the guest to power down, the guest + syncing and powering off — needs a listener in the image and is worth doing + the next time the image is rebuilt. - **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/boot.test.ts b/packages/codev-sandbox/src/boot.test.ts index 120648991c..e769f364b1 100644 --- a/packages/codev-sandbox/src/boot.test.ts +++ b/packages/codev-sandbox/src/boot.test.ts @@ -16,6 +16,9 @@ test("boot env mirrors the WSL sidecar contract and carries the server address", // channel — a dev build's channel is the host repo's branch — so every // branch switch hid the sandbox's sessions behind a fresh database. CODEV_DISABLE_CHANNEL_DB: "1", + // Stopping a sandbox cuts the VM's power, so guest commits have to reach + // the disk rather than the guest's page cache. + CODEV_SQLITE_SYNCHRONOUS: "FULL", }) }) diff --git a/packages/codev-sandbox/src/boot.ts b/packages/codev-sandbox/src/boot.ts index c73b925aa6..82eb68ad34 100644 --- a/packages/codev-sandbox/src/boot.ts +++ b/packages/codev-sandbox/src/boot.ts @@ -41,6 +41,15 @@ export function sandboxBootEnv( // migration migrates it for the others, which is the trade upstream avoids // on the host and which is worth making here. CODEV_DISABLE_CHANNEL_DB: "1", + // Commit to the disk, not to the guest's page cache. Stopping a sandbox + // cuts the VM's power (Virtualization.framework's stop is documented as + // pulling the cable, and the guest gets no chance to sync), so the + // server's default WAL + synchronous=NORMAL — durable across a crashed + // process, not across a lost machine — dropped every session created in + // the last seconds before a stop, a restart to apply a mount, or a force + // quit. Measured: a file written in the guest and left unsynced is gone + // after a plain Stop/Start. + CODEV_SQLITE_SYNCHRONOUS: "FULL", } } diff --git a/packages/core/src/database/database.ts b/packages/core/src/database/database.ts index aa652a1488..32a3e472a2 100644 --- a/packages/core/src/database/database.ts +++ b/packages/core/src/database/database.ts @@ -25,7 +25,7 @@ const layer = Layer.effect( const db = yield* makeDatabase yield* db.run("PRAGMA journal_mode = WAL") - yield* db.run("PRAGMA synchronous = NORMAL") + yield* db.run(`PRAGMA synchronous = ${synchronous()}`) yield* db.run("PRAGMA busy_timeout = 5000") yield* db.run("PRAGMA cache_size = -64000") yield* db.run("PRAGMA foreign_keys = ON") @@ -40,6 +40,17 @@ export function layerFromPath(filename: string) { return layer.pipe(Layer.provide(sqliteLayer({ filename }))) } +// WAL + NORMAL survives a process crash but not the machine going away: those +// commits are only in the page cache until the next checkpoint. That is the +// normal case for a sandbox guest, whose stop cuts the VM's power, so those +// guests ask for FULL through this. Whitelisted because it lands in a PRAGMA. +const SYNCHRONOUS = new Set(["OFF", "NORMAL", "FULL", "EXTRA"]) + +function synchronous() { + const requested = Flag.CODEV_SQLITE_SYNCHRONOUS?.toUpperCase() + return requested && SYNCHRONOUS.has(requested) ? requested : "NORMAL" +} + export function path() { if (Flag.CODEV_DB) { if (Flag.CODEV_DB === ":memory:" || isAbsolute(Flag.CODEV_DB)) return Flag.CODEV_DB diff --git a/packages/core/src/flag/flag.ts b/packages/core/src/flag/flag.ts index 3f72859f24..a1e238c349 100644 --- a/packages/core/src/flag/flag.ts +++ b/packages/core/src/flag/flag.ts @@ -45,6 +45,7 @@ export const Flag = { CODEV_MODELS_URL: process.env["CODEV_MODELS_URL"], CODEV_MODELS_PATH: process.env["CODEV_MODELS_PATH"], CODEV_DB: process.env["CODEV_DB"], + CODEV_SQLITE_SYNCHRONOUS: process.env["CODEV_SQLITE_SYNCHRONOUS"], CODEV_WORKSPACE_ID: process.env["CODEV_WORKSPACE_ID"], CODEV_EXPERIMENTAL_WORKSPACES: enabledByExperimental("CODEV_EXPERIMENTAL_WORKSPACES"), From aa5791c5eb323561617c23c531a01b95d4f97964 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Sun, 16 Aug 2026 22:25:52 +0700 Subject: [PATCH 3/3] fix(sandbox): clear a broker socket left behind by a crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Force quitting the app left the broker's unix socket file in place, and the next start failed with EADDRINUSE binding onto it — the sandbox could not start at all until someone found the file in the temp directory and deleted it. Hit while force quitting the app to test the durability fix, so it is exactly the state a crash leaves behind. The vz helper already unlinks its own socket before binding; the broker's is bound in TypeScript and did not. It now clears the file, but only when nothing answers on it: a socket with a live listener behind it belongs to another instance of the sandbox, and failing loudly is the right outcome there. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 17 ++++++++++++++++ packages/codev-sandbox/src/broker.test.ts | 21 +++++++++++++++++++- packages/codev-sandbox/src/broker.ts | 24 +++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba5efad922..c7c6c290db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,23 @@ file is the human-readable summary of what changed and why. tarball, packed in by accident whenever a release was retried. Removing it takes each platform package from ~112MB back to ~55MB. +### Sandbox + +- **Sessions in a sandbox survive stopping it** — anything the sandbox had + written in the seconds before it stopped was thrown away: stop a sandbox, or + add a folder to one (which restarts it), or quit the app, and the sessions + you had just started were gone when it came back. Stopping a sandbox cuts + its power, and the sandbox's database was set up to survive a crashed + program rather than a machine that vanishes, so the most recent writes never + reached the disk. A sandbox now commits its work to disk as it goes. Older + sessions were never lost — only the newest ones, and only the ones that had + not been written out yet. +- **A sandbox starts again after the app is force quit** — force quitting the + app (or a crash) left a file behind that the sandbox needed to create on its + next start, so the sandbox failed to start from then on, with `address + already in use` and no way forward short of finding the file in a temporary + directory and deleting it. That leftover is now cleared on the next start. + ### Desktop / Web UI - **A folder on Work is unmounted from its own menu** — the menu on a **Work** diff --git a/packages/codev-sandbox/src/broker.test.ts b/packages/codev-sandbox/src/broker.test.ts index 27d1dc7746..0e6432d37a 100644 --- a/packages/codev-sandbox/src/broker.test.ts +++ b/packages/codev-sandbox/src/broker.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test" -import { mkdtemp, rm } from "node:fs/promises" +import { mkdtemp, rm, writeFile } from "node:fs/promises" import { tmpdir } from "node:os" import { dirname, join } from "node:path" import { createBrokerServer, forwardableRequestHeaders, parseBrokerRules, resolveBrokerTarget } from "./broker" @@ -176,3 +176,22 @@ test("reports upstream failures as 502 instead of hanging", async () => { expect(body.error).toBe("sandbox egress upstream failed") await broker.close() }) + +test("binds over a socket a crash left behind, but not one still in use", async () => { + const dir = await mkdtemp(join(tmpdir(), "codev-broker-")) + const path = join(dir, "broker.sock") + await writeFile(path, "") + + const broker = createBrokerServer({ routes: [] }) + await broker.listenUnix(path) + await broker.close() + + // A socket with a live listener belongs to another instance: leave it alone + // and let the bind fail rather than taking the sandbox out from under it. + const live = createBrokerServer({ routes: [] }) + await live.listenUnix(path) + const second = createBrokerServer({ routes: [] }) + await expect(second.listenUnix(path)).rejects.toThrow() + await live.close() + await rm(dir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/broker.ts b/packages/codev-sandbox/src/broker.ts index 5d7c628aaa..aab72937f0 100644 --- a/packages/codev-sandbox/src/broker.ts +++ b/packages/codev-sandbox/src/broker.ts @@ -1,4 +1,6 @@ import { createServer } from "node:http" +import { rm } from "node:fs/promises" +import { connect } from "node:net" import { Readable } from "node:stream" // The broker is the sandbox's only egress path. The guest has no network @@ -161,6 +163,15 @@ export function createBrokerServer(options: { routes: BrokerRoute[]; fetchImpl?: // For bwrap guests the broker listens on a unix socket bind-mounted into // the sandbox; unix sockets cross the guest's network namespace. async listenUnix(path: string) { + // A socket file outlives the process that bound it. Node unlinks it on a + // clean close, so what is left here is the residue of a crash or a force + // quit — and binding onto it fails EADDRINUSE, which left the sandbox + // unable to start at all until someone deleted a file out of the temp + // directory. (The vz helper already unlinks its own; this one is bound + // here.) Only remove it when nothing answers: a socket with a listener + // behind it belongs to another instance of this sandbox, and failing + // loudly is the right outcome there. + if (await socketIsStale(path)) await rm(path, { force: true }) await new Promise((resolve, reject) => { server.once("error", reject) server.listen(path, () => resolve()) @@ -171,3 +182,16 @@ export function createBrokerServer(options: { routes: BrokerRoute[]; fetchImpl?: }, } } + +function socketIsStale(path: string) { + return new Promise((resolve) => { + const probe = connect(path) + const settle = (stale: boolean) => { + probe.destroy() + resolve(stale) + } + probe.once("connect", () => settle(false)) + // Refused (nothing listening) or absent — either way binding is safe. + probe.once("error", () => settle(true)) + }) +}