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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,18 @@ file is the human-readable summary of what changed and why.

### Desktop / Web UI

- **A sandbox's project is named after its folder** — inside a sandbox your
folder is mounted at a fixed path, so every sandbox showed the same
meaningless **workspace** label in the sidebar, tabs and command palette.
Each sandbox now names its project after the folder it was created with, so
a sandbox on `~/repos/codev-hub` reads **codev-hub**. Existing sandboxes
pick the name up on next launch, and a project you renamed yourself is left
alone.
- **The Add sandbox dialog explains the project folder** — the folder is the
only part of your computer a sandbox can reach, it cannot be changed after
the sandbox is created, and the agent edits files in it without asking for
permission. The dialog now says so above the field instead of leaving it to
be discovered.
- **Sandboxes now start on Linux** — every sandbox on Linux failed immediately
with `Can't mkdir /opt/codev-bridge: Read-only file system`. The guest's
`/opt` comes from the read-only image, so the sandbox could not create its
Expand Down
4 changes: 2 additions & 2 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,9 +190,9 @@ from the squash message first, or use a plain merge.
| `packages/desktop/src/main/updater.ts` | `autoUpdater.allowDowngrade` `true` → `false`. | Upstream's `true` is safe against immutable GitHub release tags. The fork's feed is a mutable bucket path, where re-uploading or failing to replace a stale `latest.yml` would actively downgrade every client on the next 10-minute poll. | Keep `false`; take upstream's other updater settings. `channel = "latest"` and `allowPrerelease = false` stay as upstream has them — the first must match `publish.channel` in `electron-builder.config.ts`, the second is inert for the `generic` provider (it only steers GitHub tag resolution). |
| Dual-path WSL install: `packages/desktop/src/main/wsl/{runtime,servers}.ts` | `runtime.ts`: three internals gain `export` (`runPowerShell`, `runInteractiveCommand`, `resolveSystem32Command`) — no behavior change. `servers.ts`: the `install-wsl` job calls `installWslRuntimeDualPath` and the `install-distro` job calls `installWslDistroDualPath` (both from the fork-only `install-bundle.ts`, which holds all new logic) instead of `installWslRuntimeElevated` / `installWslDistro` directly; both return the same `WslCommandResult` shape, so the surrounding job/error handling is untouched. | The customer fleet sits behind whitelist-only proxies that cannot reach Microsoft's servers. The dual path downloads the pinned bundle (`wsl.zip`: runtime MSI + Ubuntu-24.04 image) from the codev storage host, enables VirtualMachinePlatform via offline dism + installs the MSI behind one UAC prompt, and falls back to upstream's `wsl --install` / `--web-download` paths only when the bundle cannot be *obtained* — a completed-but-failed elevated install is returned as-is, never retried behind a second UAC prompt. | Keep the two call swaps and the three `export` keywords; take upstream's surrounding changes. If upstream reworks `installWslRuntimeElevated` / `installWslDistro`, keep them exported from `runtime.ts` — `install-bundle.ts` calls them as the fallback path. |
| Desktop sandbox wiring: `packages/desktop/src/main/{index,store-keys}.ts`, `packages/desktop/package.json` | Hooks the fork-only `src/main/sandbox/{bundle,controller,ipc}.ts` modules into startup: `index.ts` constructs the sandbox controller beside the WSL servers controller, adds `sandboxes.stopAll()` to `stopSidecars`, registers the `sandbox-*` IPC channels next to the WSL ones, and calls `sandboxes.initialize()` (all platforms) once the local server is ready; `store-keys.ts` gains `SANDBOXES_KEY = "sandboxes"`; `package.json` gains the `@codev/sandbox` and `@codev/gateway` workspace devDependencies (bundled into the main process by electron-vite, so not runtime dependencies; gateway supplies the sandbox egress broker's credentials and config fragment). | The desktop app is where sandboxes are created and run; `createSandboxController` generalizes the proven WSL-servers controller pattern to the per-platform `@codev/sandbox` providers (macOS vz / Windows wsl / Linux bwrap). | Keep the wiring lines and take upstream's surrounding changes; they are additive and order-insensitive apart from `initialize()` needing the local server ready first. Do not rename the `sandbox-*` channels or the `sandboxes` store key — preload/renderer and persisted sandbox configs depend on them. |
| Desktop sandbox renderer slice: `packages/app/package.json`, `packages/app/src/{index.ts,app.tsx}`, `packages/app/src/context/{platform,server}.tsx`, `packages/app/src/components/{settings-v2/servers.tsx,dialog-select-server.tsx}`, `packages/app/src/wsl/settings.tsx`, `packages/app/src/i18n/{en,es}.ts`, `packages/desktop/src/preload/{index,types}.ts`, `packages/desktop/src/renderer/index.tsx` | Surfaces the fork-only sandbox modules (`packages/app/src/sandbox/*`, `packages/desktop/src/renderer/sandbox/*`) in the UI: app `package.json` exports `./sandbox/types` (mirror of `./wsl/types`); `index.ts` re-exports `useSandboxes` + the sandbox types; `app.tsx` mounts `SandboxesProvider` inside `WslServersProvider`; `platform.tsx` gains optional `sandboxes?: SandboxesPlatform`; `server.tsx` adds the third `ServerConnection.Sidecar` variant `{ variant: "sandbox"; name }` with key `sandbox:<name>`; `settings-v2/servers.tsx` filters sandbox rows out of the generic list and renders `SandboxServerSettings` beside the WSL section; `dialog-select-server.tsx` `handleRemove` routes `sandbox:` keys to `platform.sandboxes.remove` (like `wsl:` keys) 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); 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:<name>` 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? }` with key `sandbox:<name>` (`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:<name>` 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. |

| `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. | 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. |
| `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. |

| `packages/app/src/pages/layout/project-avatar-state.ts` | `unread` gains a `connection()` guard before `notification.ensureServerState(server())`, matching the guard `hasPermissions`/`hasQuestions`/`loading` already apply to `sync()`. | `ensureServerState` throws on a key absent from the connection list, and the throw propagates out of a render-time memo, killing the whole renderer. Sidecar connections are listed only while healthy — `readySandboxConnections`/`readyWslConnections` publish a sandbox or WSL server only in the `ready` state — so any removal, restart, or crash of one drops its key while its session tabs are still mounted. Reproduced by removing a sandbox that had an open session tab: `Error: Notification server not found: sandbox:<name>`. | Keep the guard; take upstream's other changes to the hook. This is an upstream bug (WSL servers hit the identical path), so if upstream fixes it their way, drop ours in favour of theirs. |
| `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. |
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/context/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,10 @@ export namespace ServerConnection {
| {
variant: "sandbox"
name: string
// Host path mounted into the guest. The guest only ever sees its own
// mount point, so this is the one place the app can recover which
// folder a sandbox is actually working on.
projectDir?: string
}
) &
Base
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,8 @@ export const dict = {
"sandbox.add.namePlaceholder": "Sandbox name",
"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.",
"sandbox.add.choose": "Choose...",
"sandbox.add.create": "Create",
"sandbox.add.cpus": "CPUs (optional)",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,8 @@ export const dict = {
"sandbox.add.namePlaceholder": "Nombre del sandbox",
"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.",
"sandbox.add.choose": "Elegir...",
"sandbox.add.create": "Crear",
"sandbox.add.cpus": "CPU (opcional)",
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/sandbox/dialog-add.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,10 @@ export function DialogAddSandbox() {
<Show when={nameInvalid()}>
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.add.nameInvalid")}</p>
</Show>
{/* The folder is the sandbox's only reach into this machine and is
fixed for its lifetime, so say so before the picker opens rather
than leaving it to be discovered. */}
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.add.projectDirHint")}</p>
<div class="flex items-center gap-2">
<TextInputV2
class="flex-1"
Expand Down
63 changes: 54 additions & 9 deletions packages/app/src/sandbox/projects.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,29 @@
import { createEffect } from "solid-js"
import { getFilename } from "@opencode-ai/core/util/path"
import type { ServerConnection } from "@/context/server"

// The slice of a server ctx (context/global.tsx) the seed reads. Structural on
// purpose: it keeps this testable without standing up a whole ctx, and a shape
// change upstream surfaces as a type error at the call site.
// The slice of a server ctx (context/global.tsx) these effects read. Structural
// on purpose: it keeps this testable without standing up a whole ctx, and a
// shape change upstream surfaces as a type error at the call site.
type SandboxServerCtx = {
sdk: {
client: {
project: {
update: (input: { projectID: string; directory: string; name: string }) => Promise<unknown>
}
}
}
projects: {
list: () => { worktree: string }[]
recentlyClosed: () => { worktree: string }[]
open: (directory: string) => void
}
sync: { data: { path: { directory: string; worktree: string } } }
sync: {
data: {
path: { directory: string; worktree: string }
project: { id: string; worktree: string; name?: string }[]
}
}
}

// A sandbox exposes exactly one directory: the project folder it was created
Expand All @@ -27,17 +40,49 @@ export function seedSandboxProjects(
connections: () => ServerConnection.Any[],
ensure: (conn: ServerConnection.Any) => SandboxServerCtx,
) {
// A project is named once per app run. The rename lands back as a
// project.updated event, but the effect can re-run before it arrives.
const named = new Set<string>()

createEffect(() => {
for (const conn of connections()) {
if (conn.type !== "sidecar" || conn.variant !== "sandbox") continue
const ctx = ensure(conn)
if (ctx.projects.list().length) continue
const directory = ctx.sync.data.path.directory || ctx.sync.data.path.worktree
if (!directory) continue
// Reopening a project the user closed on purpose would make it
// impossible to remove.
if (ctx.projects.recentlyClosed().some((project) => project.worktree === directory)) continue
ctx.projects.open(directory)
if (!ctx.projects.list().length) {
// 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)
}
void adoptFolderName(ctx, conn.projectDir, directory, named, `${conn.name}\n${directory}`)
}
})
}

// Inside the guest the mount point is all there is, so the project would read
// "workspace" in the sidebar, tabs and palette — the same label for every
// sandbox on the machine. Name it after the host folder the sandbox actually
// works on. Written through the server so the name persists in the guest and
// reaches every surface, exactly as renaming the project by hand would.
async function adoptFolderName(
ctx: SandboxServerCtx,
projectDir: string | undefined,
directory: string,
named: Set<string>,
key: string,
) {
if (!projectDir || named.has(key)) return
const project = ctx.sync.data.project.find((item) => item.worktree === directory)
// No name only ever means "never named": the guest leaves it unset, and a
// name the user chose must survive the next launch.
if (!project || project.name) return
const name = getFilename(projectDir)
if (!name) return
named.add(key)
await ctx.sdk.client.project.update({ projectID: project.id, directory, name }).catch(() => {
// Best effort: a failed rename costs a label, not the sandbox.
named.delete(key)
})
}
Loading
Loading