From 9d9682ec6bb9e62e03049d6829277b0d7ac8f8f0 Mon Sep 17 00:00:00 2001
From: Quickbeard
Date: Fri, 14 Aug 2026 13:18:32 +0700
Subject: [PATCH] feat(desktop): manage extra sandbox folder mounts from
settings
Each sandbox's overflow menu (Settings -> Servers) gains a Folders...
item opening a manage-mounts dialog: the original project folder stays
fixed, extra host folders can be added (multi-select native picker) and
removed, read-write. Windows applies changes hot; macOS and Linux warn
that saving restarts the sandbox (disk state survives, the live session
drops), and macOS also hints that an old-image sandbox needs Reset
before new folders appear.
Wiring: a sandbox-update-mounts IPC channel validates shape, structure
(shared validateMountPaths: duplicates, nesting, UNC), and existence
main-side before calling the controller; normalizePersistedSandbox
tolerates old records and drops malformed mount entries rather than the
sandbox; the renderer connection carries the resolved runtime mounts.
The app mirrors the mount types (it never imports the node package),
validates the draft inline via mounts-model.ts for i18n-able messages,
and seedSandboxProjects now opens one project per mounted folder,
naming each after its host basename. The "cannot be changed later"
copy in the add dialog is reworded in en and es.
Co-Authored-By: Claude Fable 5
---
CHANGELOG.md | 11 ++
DIVERGENCES.md | 2 +-
packages/app/src/context/server.tsx | 3 +
packages/app/src/i18n/en.ts | 18 ++-
packages/app/src/i18n/es.ts | 18 ++-
packages/app/src/sandbox/dialog-mounts.tsx | 151 ++++++++++++++++++
packages/app/src/sandbox/mounts-model.test.ts | 37 +++++
packages/app/src/sandbox/mounts-model.ts | 36 +++++
packages/app/src/sandbox/projects.ts | 40 +++--
packages/app/src/sandbox/settings.tsx | 9 ++
packages/app/src/sandbox/types.ts | 20 ++-
.../desktop/src/main/sandbox/controller.ts | 11 +-
packages/desktop/src/main/sandbox/ipc.ts | 38 ++++-
packages/desktop/src/preload/index.ts | 1 +
.../src/renderer/sandbox/connections.ts | 1 +
15 files changed, 374 insertions(+), 22 deletions(-)
create mode 100644 packages/app/src/sandbox/dialog-mounts.tsx
create mode 100644 packages/app/src/sandbox/mounts-model.test.ts
create mode 100644 packages/app/src/sandbox/mounts-model.ts
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 6191097eab..eca3536214 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -9,6 +9,17 @@ file is the human-readable summary of what changed and why.
### Desktop / Web UI
+- **Sandboxes can mount more folders after creation** — a sandbox used to be
+ locked to the one project folder chosen when it was created. Each sandbox
+ now has a **Folders…** entry in its menu (Settings → Servers) to mount
+ additional folders from this computer and remove them again; the original
+ project folder stays fixed. Every mounted folder appears as its own project
+ on the sandbox, and mounts stay read-write. On Windows changes apply
+ immediately; on macOS and Linux saving restarts the sandbox — files on its
+ disk are kept, but running terminals and in-flight work stop.
+ **On macOS this requires the updated sandbox image** — if a folder does not
+ appear inside the sandbox after saving, use the sandbox's **Reset** button
+ to pick the new image up.
- **The desktop app is now called "CoDev"** — the app spans Work alongside
Code, so the "Code" suffix no longer described it. The installed app on
macOS, Windows, and Linux, the window title, menus, dialogs, and all in-app
diff --git a/DIVERGENCES.md b/DIVERGENCES.md
index 29e04177ee..2b8b8adf97 100644
--- a/DIVERGENCES.md
+++ b/DIVERGENCES.md
@@ -196,7 +196,7 @@ from the squash message first, or use a plain merge.
| `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). |
| Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. |
| Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. |
-| Desktop sandbox renderer slice: `packages/app/package.json`, `packages/app/src/{index.ts,app.tsx}`, `packages/app/src/context/{platform,server}.tsx`, `packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}`, `packages/app/src/wsl/settings.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Surfaces the fork-only sandbox modules (`packages/app/src/sandbox/*`, `packages/desktop/src/renderer/sandbox/*`) in the UI: app `package.json` exports `./sandbox/types` (mirror of `./wsl/types`); `index.ts` re-exports `useSandboxes` + the sandbox types; `app.tsx` mounts `SandboxesProvider` inside `WslServersProvider`; `platform.tsx` gains optional `sandboxes?: SandboxesPlatform`; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name; projectDir? }` with key `sandbox:` (`projectDir` is the host folder mounted into the guest — the guest only ever sees its own mount point, so this is the only way the app can name a sandbox's project after the folder 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, and `sandbox.add.projectDirHint`, the standing explanation above the folder field); 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; 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 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/context/server.tsx b/packages/app/src/context/server.tsx
index fceb4bc3ff..efc2b0f099 100644
--- a/packages/app/src/context/server.tsx
+++ b/packages/app/src/context/server.tsx
@@ -213,6 +213,9 @@ export namespace ServerConnection {
// mount point, so this is the one place the app can recover which
// folder a sandbox is actually working on.
projectDir?: string
+ // Extra host folders shared into the running guest, with the guest
+ // paths they resolved to.
+ mounts?: { path: string; guestPath: string }[]
}
) &
Base
diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts
index dc6fd63dd1..cadc0904a0 100644
--- a/packages/app/src/i18n/en.ts
+++ b/packages/app/src/i18n/en.ts
@@ -383,6 +383,7 @@ export const dict = {
"sandbox.server.menu.label": "Sandbox",
"sandbox.server.start": "Start",
"sandbox.server.reset": "Reset",
+ "sandbox.server.mounts": "Folders...",
"sandbox.status.starting": "Starting...",
"sandbox.status.ready": "Ready",
"sandbox.status.stopped": "Stopped",
@@ -394,7 +395,7 @@ export const dict = {
"sandbox.add.nameInvalid": "Use letters, numbers, dots, dashes, or underscores.",
"sandbox.add.projectDir": "Project folder",
"sandbox.add.projectDirHint":
- "Choose the folder you want to work on. It is the only folder on this computer the sandbox can reach, it cannot be changed later, and the agent edits files in it without asking.",
+ "Choose the folder you want to work on. The sandbox can only reach folders you mount from this computer — you can mount more later — and the agent edits files in them without asking.",
"sandbox.add.choose": "Choose...",
"sandbox.add.create": "Create",
"sandbox.add.cpus": "CPUs (optional)",
@@ -402,6 +403,21 @@ export const dict = {
"sandbox.add.resourcesInvalid": "CPUs must be 1-32 and memory 1-64 GB.",
"sandbox.add.downloadHint": "The first sandbox downloads its image; on a slow connection this can take a while.",
"sandbox.add.downloadProgress": "Downloading image… {{done}}/{{total}} MB",
+ "sandbox.mounts.title": "Sandbox folders",
+ "sandbox.mounts.hint":
+ "Folders mounted here are the only ones on this computer the sandbox can reach, and the agent edits files in them without asking.",
+ "sandbox.mounts.primary": "Project folder",
+ "sandbox.mounts.empty": "No extra folders are mounted.",
+ "sandbox.mounts.add": "Add folder...",
+ "sandbox.mounts.remove": "Remove folder",
+ "sandbox.mounts.save": "Save",
+ "sandbox.mounts.restartWarning":
+ "Saving restarts the sandbox. Files on disk are kept; running terminals and in-flight work in the sandbox stop.",
+ "sandbox.mounts.imageHint":
+ "If a folder does not appear inside the sandbox after saving, reset the sandbox to update it to the current image.",
+ "sandbox.mounts.invalid.duplicate": "This folder is already mounted: {{path}}",
+ "sandbox.mounts.invalid.nested": "{{path}} overlaps a folder that is already mounted ({{other}}).",
+ "sandbox.mounts.invalid.unc": "Network paths cannot be mounted: {{path}}",
"wsl.onboarding.step.distro": "Choose distro",
"wsl.onboarding.step.opencode": "CoDev",
"wsl.onboarding.checkingRuntime": "Checking WSL...",
diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts
index 8cdacf4dc8..58b6b7cb37 100644
--- a/packages/app/src/i18n/es.ts
+++ b/packages/app/src/i18n/es.ts
@@ -374,6 +374,7 @@ export const dict = {
"sandbox.server.menu.label": "Sandbox",
"sandbox.server.start": "Iniciar",
"sandbox.server.reset": "Restablecer",
+ "sandbox.server.mounts": "Carpetas...",
"sandbox.status.starting": "Iniciando...",
"sandbox.status.ready": "Listo",
"sandbox.status.stopped": "Detenido",
@@ -385,7 +386,7 @@ export const dict = {
"sandbox.add.nameInvalid": "Usa letras, números, puntos, guiones o guiones bajos.",
"sandbox.add.projectDir": "Carpeta del proyecto",
"sandbox.add.projectDirHint":
- "Elige la carpeta en la que quieres trabajar. Es la única carpeta de este equipo a la que el sandbox puede acceder, no se puede cambiar después y el agente edita archivos en ella sin preguntar.",
+ "Elige la carpeta en la que quieres trabajar. El sandbox solo puede acceder a las carpetas de este equipo que montes — puedes montar más después — y el agente edita archivos en ellas sin preguntar.",
"sandbox.add.choose": "Elegir...",
"sandbox.add.create": "Crear",
"sandbox.add.cpus": "CPU (opcional)",
@@ -393,6 +394,21 @@ export const dict = {
"sandbox.add.resourcesInvalid": "Las CPU deben ser 1-32 y la memoria 1-64 GB.",
"sandbox.add.downloadHint": "El primer sandbox descarga su imagen; con una conexión lenta puede tardar un poco.",
"sandbox.add.downloadProgress": "Descargando imagen… {{done}}/{{total}} MB",
+ "sandbox.mounts.title": "Carpetas del sandbox",
+ "sandbox.mounts.hint":
+ "Las carpetas montadas aquí son las únicas de este equipo a las que el sandbox puede acceder, y el agente edita archivos en ellas sin preguntar.",
+ "sandbox.mounts.primary": "Carpeta del proyecto",
+ "sandbox.mounts.empty": "No hay carpetas adicionales montadas.",
+ "sandbox.mounts.add": "Añadir carpeta...",
+ "sandbox.mounts.remove": "Quitar carpeta",
+ "sandbox.mounts.save": "Guardar",
+ "sandbox.mounts.restartWarning":
+ "Al guardar se reinicia el sandbox. Los archivos en disco se conservan; las terminales abiertas y el trabajo en curso dentro del sandbox se detienen.",
+ "sandbox.mounts.imageHint":
+ "Si una carpeta no aparece dentro del sandbox después de guardar, restablece el sandbox para actualizarlo a la imagen actual.",
+ "sandbox.mounts.invalid.duplicate": "Esta carpeta ya está montada: {{path}}",
+ "sandbox.mounts.invalid.nested": "{{path}} se superpone con una carpeta ya montada ({{other}}).",
+ "sandbox.mounts.invalid.unc": "Las rutas de red no se pueden montar: {{path}}",
"wsl.onboarding.step.distro": "Elegir distribución",
"wsl.onboarding.step.opencode": "CoDev",
"wsl.onboarding.checkingRuntime": "Comprobando WSL...",
diff --git a/packages/app/src/sandbox/dialog-mounts.tsx b/packages/app/src/sandbox/dialog-mounts.tsx
new file mode 100644
index 0000000000..3fa14a63a7
--- /dev/null
+++ b/packages/app/src/sandbox/dialog-mounts.tsx
@@ -0,0 +1,151 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
+import { showToast } from "@opencode-ai/ui/toast"
+import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { Dialog, DialogBody, DialogFooter, DialogHeader, DialogTitle } from "@opencode-ai/ui/v2/dialog-v2"
+import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2"
+import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
+import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
+import { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
+import { For, Show } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useLanguage } from "@/context/language"
+import { usePlatform } from "@/context/platform"
+import { useSandboxes } from "./context"
+import { validateSandboxMounts } from "./mounts-model"
+
+// Manages the extra host folders mounted into one sandbox. The original
+// project folder stays fixed (it anchors the sandbox's identity); everything
+// else can be added and removed, applied hot on Windows and by restarting the
+// sandbox elsewhere.
+export function DialogSandboxMounts(props: { id: string }) {
+ const language = useLanguage()
+ const platform = usePlatform()
+ const dialog = useDialog()
+ const sandboxes = useSandboxes()
+ const api = platform.sandboxes!
+
+ const item = () => sandboxes.state.data?.sandboxes.find((x) => x.config.id === props.id)
+ const [store, setStore] = createStore({
+ paths: (item()?.config.mounts ?? []).map((mount) => mount.path),
+ saving: false,
+ })
+
+ const busy = () => sandboxes.state.data?.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])
+ }
+ const invalid = () => {
+ const config = item()?.config
+ return config ? validateSandboxMounts(config.projectDir, store.paths) : null
+ }
+ const invalidMessage = () => {
+ const error = invalid()
+ if (!error) return null
+ if (error.code === "duplicate") return language.t("sandbox.mounts.invalid.duplicate", { path: error.path })
+ if (error.code === "nested")
+ return language.t("sandbox.mounts.invalid.nested", { path: error.path, other: error.other })
+ return language.t("sandbox.mounts.invalid.unc", { path: error.path })
+ }
+ // Windows applies mounts hot; everywhere else a live sandbox restarts.
+ const willRestart = () => {
+ const runtime = item()?.runtime
+ return (
+ platform.os !== "windows" && dirty() && (runtime?.kind === "ready" || runtime?.kind === "starting")
+ )
+ }
+ const canSave = () => dirty() && !invalid() && !busy() && !store.saving && item() !== undefined
+
+ const addFolders = () => {
+ if (platform.platform !== "desktop") return
+ void platform
+ .openDirectoryPickerDialog({ title: language.t("sandbox.mounts.add"), multiple: true })
+ .then((result) => {
+ if (!result) return
+ const picked = typeof result === "string" ? [result] : result
+ setStore("paths", (paths) => [...paths, ...picked.filter((path) => !paths.includes(path))])
+ })
+ }
+
+ const save = async () => {
+ if (!canSave()) return
+ setStore("saving", true)
+ try {
+ await api.updateMounts(
+ props.id,
+ store.paths.map((path) => ({ path })),
+ )
+ dialog.close()
+ } catch (error) {
+ showToast({
+ variant: "error",
+ title: language.t("common.requestFailed"),
+ description: error instanceof Error ? error.message : String(error),
+ })
+ } finally {
+ setStore("saving", false)
+ }
+ }
+
+ return (
+
}
+ >
+
+ {(path, index) => (
+
+ {path}
+ }
+ aria-label={language.t("sandbox.mounts.remove")}
+ disabled={store.saving}
+ onClick={() => setStore("paths", (paths) => paths.filter((_, i) => i !== index()))}
+ />
+
+ )}
+
+
+
+
+ {language.t("sandbox.mounts.add")}
+
+
+ {(text) => {text()}
}
+
+ {language.t("sandbox.mounts.restartWarning")}
+
+
+ {language.t("sandbox.mounts.imageHint")}
+
+
+
+ dialog.close()}>
+ {language.t("common.cancel")}
+
+ void save()}
+ >
+
+
+
+
+
+
+ )
+}
diff --git a/packages/app/src/sandbox/mounts-model.test.ts b/packages/app/src/sandbox/mounts-model.test.ts
new file mode 100644
index 0000000000..5e02761500
--- /dev/null
+++ b/packages/app/src/sandbox/mounts-model.test.ts
@@ -0,0 +1,37 @@
+import { expect, test } from "bun:test"
+import { validateSandboxMounts } from "./mounts-model"
+
+test("accepts distinct sibling folders", () => {
+ expect(validateSandboxMounts("/home/user/project", ["/home/user/docs", "/srv/data"])).toBeNull()
+})
+
+test("rejects duplicates, case-insensitively for Windows paths", () => {
+ expect(validateSandboxMounts("/p", ["/a", "/a/"])).toEqual({ code: "duplicate", path: "/a/" })
+ expect(validateSandboxMounts("C:\\p", ["C:\\Data", "c:\\data"])).toEqual({ code: "duplicate", path: "c:\\data" })
+ // Unix paths stay case-sensitive.
+ expect(validateSandboxMounts("/p", ["/a/Data", "/a/data"])).toBeNull()
+})
+
+test("rejects nesting in either direction, against the project dir and other mounts", () => {
+ expect(validateSandboxMounts("/home/user/project", ["/home/user/project/src"])).toEqual({
+ code: "nested",
+ path: "/home/user/project/src",
+ other: "/home/user/project",
+ })
+ expect(validateSandboxMounts("/home/user/project", ["/home/user"])).toEqual({
+ code: "nested",
+ path: "/home/user",
+ other: "/home/user/project",
+ })
+ expect(validateSandboxMounts("/p", ["/data", "/data/sub"])).toEqual({
+ code: "nested",
+ path: "/data/sub",
+ other: "/data",
+ })
+ // Shared prefixes without a separator boundary are not nesting.
+ expect(validateSandboxMounts("/p", ["/data", "/database"])).toBeNull()
+})
+
+test("rejects UNC paths", () => {
+ expect(validateSandboxMounts("C:\\p", ["\\\\server\\share"])).toEqual({ code: "unc", path: "\\\\server\\share" })
+})
diff --git a/packages/app/src/sandbox/mounts-model.ts b/packages/app/src/sandbox/mounts-model.ts
new file mode 100644
index 0000000000..2d4655a984
--- /dev/null
+++ b/packages/app/src/sandbox/mounts-model.ts
@@ -0,0 +1,36 @@
+// Pure validation for the manage-mounts dialog, mirroring validateMountPaths
+// in @codev/sandbox (the app must never import that node-flavored package —
+// see types.ts). The main process re-validates on save; this copy exists for
+// inline, i18n-able feedback before the IPC round-trip.
+
+export type SandboxMountsError =
+ | { code: "duplicate"; path: string }
+ | { code: "nested"; path: string; other: string }
+ | { code: "unc"; path: string }
+
+export function validateSandboxMounts(projectDir: string, paths: string[]): SandboxMountsError | null {
+ const project = comparableMountPath(projectDir)
+ const seen = new Map()
+ for (const path of paths) {
+ if (path.startsWith("\\\\")) return { code: "unc", path }
+ const normalized = comparableMountPath(path)
+ if (seen.has(normalized)) return { code: "duplicate", path }
+ if (nests(normalized, project)) return { code: "nested", path, other: projectDir }
+ for (const [other, original] of seen) {
+ if (nests(normalized, other)) return { code: "nested", path, other: original }
+ }
+ seen.set(normalized, path)
+ }
+ return null
+}
+
+// Windows paths compare case-insensitively; detection is by shape (drive
+// letter or backslash) so the model needs no platform flag.
+function comparableMountPath(path: string) {
+ const canonical = path.replace(/[/\\]+$/, "").replace(/\\/g, "/")
+ return /^[A-Za-z]:|\\/.test(path) ? canonical.toLowerCase() : canonical
+}
+
+function nests(a: string, b: string) {
+ return a === b || a.startsWith(`${b}/`) || b.startsWith(`${a}/`)
+}
diff --git a/packages/app/src/sandbox/projects.ts b/packages/app/src/sandbox/projects.ts
index baf8332b39..41c5d26ed0 100644
--- a/packages/app/src/sandbox/projects.ts
+++ b/packages/app/src/sandbox/projects.ts
@@ -26,16 +26,18 @@ type SandboxServerCtx = {
}
}
-// A sandbox exposes exactly one directory: the project folder it was created
-// with, mounted at a fixed guest path — /workspace on macOS and Linux, the
-// translated Windows path on WSL. The guest server reports it as its own
-// directory, but the app's per-server project list starts empty and nothing
-// ever fills it, so every "New session" entry point finds no project for the
-// sandbox and falls back to another server: the sandbox looks selected while
-// the session runs on the local one.
+// A sandbox exposes the project folder it was created with — mounted at a
+// fixed guest path: /workspace on macOS and Linux, the translated Windows
+// path on WSL — plus any extra folders mounted later, each at its own guest
+// path. The guest server reports the primary as its own directory, but the
+// app's per-server project list starts empty and nothing ever fills it, so
+// every "New session" entry point finds no project for the sandbox and falls
+// back to another server: the sandbox looks selected while the session runs
+// on the local one.
//
-// Seed it from the server itself rather than making the user find the path in
-// a directory picker that opens on the guest home (/home/codev) instead.
+// Seed one project per mounted folder from the server itself rather than
+// making the user find the paths in a directory picker that opens on the
+// guest home (/home/codev) instead.
export function seedSandboxProjects(
connections: () => ServerConnection.Any[],
ensure: (conn: ServerConnection.Any) => SandboxServerCtx,
@@ -48,15 +50,21 @@ export function seedSandboxProjects(
for (const conn of connections()) {
if (conn.type !== "sidecar" || conn.variant !== "sandbox") continue
const ctx = ensure(conn)
- const directory = ctx.sync.data.path.directory || ctx.sync.data.path.worktree
- if (!directory) continue
- if (!ctx.projects.list().length) {
+ const primary = ctx.sync.data.path.directory || ctx.sync.data.path.worktree
+ if (!primary) continue
+ const mounted = [
+ { directory: primary, hostPath: conn.projectDir },
+ ...(conn.mounts ?? []).map((mount) => ({ directory: mount.guestPath, hostPath: mount.path })),
+ ]
+ for (const entry of mounted) {
+ const open = ctx.projects.list().some((project) => project.worktree === entry.directory)
// Reopening a project the user closed on purpose would make it
- // impossible to remove.
- const closed = ctx.projects.recentlyClosed().some((project) => project.worktree === directory)
- if (!closed) ctx.projects.open(directory)
+ // impossible to remove. A mount removed from the sandbox keeps its
+ // project open until the user closes it — sessions may reference it.
+ const closed = ctx.projects.recentlyClosed().some((project) => project.worktree === entry.directory)
+ if (!open && !closed) ctx.projects.open(entry.directory)
+ void adoptFolderName(ctx, entry.hostPath, entry.directory, named, `${conn.name}\n${entry.directory}`)
}
- void adoptFolderName(ctx, conn.projectDir, directory, named, `${conn.name}\n${directory}`)
}
})
}
diff --git a/packages/app/src/sandbox/settings.tsx b/packages/app/src/sandbox/settings.tsx
index 3bab5d12a7..15a162a1d8 100644
--- a/packages/app/src/sandbox/settings.tsx
+++ b/packages/app/src/sandbox/settings.tsx
@@ -1,3 +1,4 @@
+import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
@@ -12,6 +13,7 @@ import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { showToast } from "@/utils/toast"
import { useSandboxes } from "./context"
+import { DialogSandboxMounts } from "./dialog-mounts"
import type { SandboxRuntime } from "./types"
type Controller = ReturnType
@@ -42,6 +44,7 @@ export function SandboxServerSettings(props: {
}) {
const platform = usePlatform()
const language = useLanguage()
+ const dialog = useDialog()
const sandboxes = useSandboxes()
const api = platform.sandboxes
@@ -106,6 +109,12 @@ export function SandboxServerSettings(props: {
api && request.mutate(() => api.reset(key))}>
{language.t("sandbox.server.reset")}
+ dialog.push(() => )}
+ >
+ {language.t("sandbox.server.mounts")}
+
props.controller.setDefault(key)}>
{language.t("dialog.server.menu.default")}
diff --git a/packages/app/src/sandbox/types.ts b/packages/app/src/sandbox/types.ts
index 032a6dae3e..fd052cc960 100644
--- a/packages/app/src/sandbox/types.ts
+++ b/packages/app/src/sandbox/types.ts
@@ -10,8 +10,23 @@ export type SandboxConfig = {
name: string
backend: SandboxBackend
projectDir: string
+ // Extra host folders shared into the guest alongside projectDir; absent
+ // means none. Objects so a readOnly flag can be added without migration.
+ mounts?: SandboxMount[]
} & SandboxResources
+export type SandboxMount = {
+ path: string
+}
+
+// A mount as the running guest actually sees it: guestPath is /mnt/ on
+// vz/bwrap and the wslpath translation on WSL. Mounts the provider skipped
+// (folder deleted since it was added) are absent.
+export type ResolvedSandboxMount = {
+ path: string
+ guestPath: string
+}
+
// Per-sandbox limits. Only the macOS vz backend applies them: WSL's limits are
// VM-global (.wslconfig) and bubblewrap has no cgroup plumbing here, so the UI
// offers the fields only where they take effect.
@@ -19,7 +34,7 @@ export type SandboxResources = { cpus?: number; memoryMiB?: number }
export type SandboxRuntime =
| { kind: "starting" }
- | { kind: "ready"; url: string; username: string | null; password: string | null }
+ | { kind: "ready"; url: string; username: string | null; password: string | null; mounts: ResolvedSandboxMount[] }
| { kind: "failed"; message: string }
| { kind: "stopped" }
@@ -59,4 +74,7 @@ export type SandboxesPlatform = {
remove(id: string): Promise
reset(id: string): Promise
start(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/desktop/src/main/sandbox/controller.ts b/packages/desktop/src/main/sandbox/controller.ts
index c0e6080115..df943b7a5e 100644
--- a/packages/desktop/src/main/sandbox/controller.ts
+++ b/packages/desktop/src/main/sandbox/controller.ts
@@ -107,13 +107,22 @@ function normalizePersistedSandbox(value: unknown): SandboxConfig[] {
...(typeof record.memoryMiB === "number" ? { memoryMiB: record.memoryMiB } : {}),
}
// A persisted file edited by hand can carry nonsense; fall back to the
- // provider defaults rather than refusing to load the sandbox at all.
+ // provider defaults rather than refusing to load the sandbox at all. Same
+ // for mounts: malformed entries are dropped, never the whole sandbox.
+ const mounts = Array.isArray(record.mounts)
+ ? record.mounts.flatMap((entry) => {
+ if (!entry || typeof entry !== "object") return []
+ const path = (entry as Record).path
+ return typeof path === "string" && path.length ? [{ path }] : []
+ })
+ : []
return [
{
id: record.id,
name: record.name,
backend: record.backend,
projectDir: record.projectDir,
+ ...(mounts.length ? { mounts } : {}),
...(sandboxResourcesValid(resources) ? resources : {}),
},
]
diff --git a/packages/desktop/src/main/sandbox/ipc.ts b/packages/desktop/src/main/sandbox/ipc.ts
index 3ffda87155..3a6f2714c7 100644
--- a/packages/desktop/src/main/sandbox/ipc.ts
+++ b/packages/desktop/src/main/sandbox/ipc.ts
@@ -1,6 +1,8 @@
+import { stat } from "node:fs/promises"
import { app, ipcMain } from "electron"
import type { IpcMainInvokeEvent } from "electron"
-import { sandboxResourcesValid, sandboxServerId } from "@codev/sandbox"
+import { sandboxResourcesValid, sandboxServerId, validateMountPaths } from "@codev/sandbox"
+import type { MountValidationError } from "@codev/sandbox"
import type { DesktopSandboxController } from "./controller"
// Unlike the WSL channels there is no unavailable-stub variant: the controller
@@ -66,6 +68,40 @@ export function registerSandboxIpcHandlers(controller: DesktopSandboxController)
ipcMain.handle("sandbox-start", (_event: IpcMainInvokeEvent, id: unknown) =>
controller.startSandbox(requireSandboxIpcString("sandbox id", id)),
)
+ // Filesystem checks live here rather than in the generic controller so the
+ // controller stays fs-free; the dialog validates the same rules client-side
+ // for friendly messages, these are the authoritative backstop.
+ ipcMain.handle("sandbox-update-mounts", async (_event: IpcMainInvokeEvent, id: unknown, mounts: unknown) => {
+ const sandboxId = requireSandboxIpcString("sandbox id", id)
+ const parsed = requireSandboxMounts(mounts)
+ const item = controller.getState().sandboxes.find((x) => x.config.id === sandboxId)
+ if (!item) throw new Error(`no sandbox with id ${sandboxId}`)
+ const invalid = validateMountPaths(item.config.projectDir, parsed)
+ if (invalid) throw new Error(mountValidationMessage(invalid))
+ for (const mount of parsed) {
+ const found = await stat(mount.path).catch(() => null)
+ if (!found?.isDirectory()) throw new Error(`folder does not exist: ${mount.path}`)
+ }
+ return controller.updateSandboxMounts(sandboxId, parsed)
+ })
+}
+
+const SANDBOX_MOUNTS_MAX = 16
+
+function requireSandboxMounts(value: unknown) {
+ if (!Array.isArray(value) || value.length > SANDBOX_MOUNTS_MAX) throw new Error("Invalid sandbox mounts")
+ return value.map((entry) => {
+ if (!entry || typeof entry !== "object") throw new Error("Invalid sandbox mounts")
+ const path = (entry as Record).path
+ if (typeof path !== "string" || !path.length) throw new Error("Invalid sandbox mounts")
+ return { path }
+ })
+}
+
+function mountValidationMessage(error: MountValidationError) {
+ if (error.code === "duplicate") return `folder is already mounted: ${error.path}`
+ if (error.code === "nested") return `folder overlaps another mounted folder: ${error.path} and ${error.other}`
+ return `network (UNC) paths cannot be mounted: ${error.path}`
}
function requireSandboxIpcString(name: string, value: unknown) {
diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts
index e652ae2253..c7d1194ce3 100644
--- a/packages/desktop/src/preload/index.ts
+++ b/packages/desktop/src/preload/index.ts
@@ -53,6 +53,7 @@ const api: ElectronAPI = {
remove: (id) => ipcRenderer.invoke("sandbox-remove", id),
reset: (id) => ipcRenderer.invoke("sandbox-reset", id),
start: (id) => ipcRenderer.invoke("sandbox-start", id),
+ updateMounts: (id, mounts) => ipcRenderer.invoke("sandbox-update-mounts", id, mounts),
},
updater: {
subscribe: async (cb) => {
diff --git a/packages/desktop/src/renderer/sandbox/connections.ts b/packages/desktop/src/renderer/sandbox/connections.ts
index 9bf345fa15..90f74d633a 100644
--- a/packages/desktop/src/renderer/sandbox/connections.ts
+++ b/packages/desktop/src/renderer/sandbox/connections.ts
@@ -13,6 +13,7 @@ export function readySandboxConnections(state?: SandboxesState) {
variant: "sandbox" as const,
name: item.config.name,
projectDir: item.config.projectDir,
+ mounts: item.runtime.mounts,
http: {
url: item.runtime.url,
username: item.runtime.username ?? undefined,