diff --git a/CHANGELOG.md b/CHANGELOG.md index b0a7c4d1de..1ead53370b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/DIVERGENCES.md b/DIVERGENCES.md index a8a34e0eff..d56599d1f1 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -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:`; `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:` 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:` (`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. | -| `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:`. | 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. | diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 18560ad41b..fceb4bc3ff 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -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 diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 633e411f6f..273d73da1b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -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)", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 69a1804834..6641d214b3 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -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)", diff --git a/packages/app/src/sandbox/dialog-add.tsx b/packages/app/src/sandbox/dialog-add.tsx index 7bc122144d..f8de22d06b 100644 --- a/packages/app/src/sandbox/dialog-add.tsx +++ b/packages/app/src/sandbox/dialog-add.tsx @@ -122,6 +122,10 @@ export function DialogAddSandbox() {

{language.t("sandbox.add.nameInvalid")}

+ {/* 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. */} +

{language.t("sandbox.add.projectDirHint")}

Promise + } + } + } 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 @@ -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() + 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, + 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) + }) +} diff --git a/packages/app/test-browser/sandbox-projects.test.ts b/packages/app/test-browser/sandbox-projects.test.ts index 8a9df74f69..60c721bfd9 100644 --- a/packages/app/test-browser/sandbox-projects.test.ts +++ b/packages/app/test-browser/sandbox-projects.test.ts @@ -3,12 +3,13 @@ import { createRoot, createSignal } from "solid-js" import { ServerConnection } from "@/context/server" import { seedSandboxProjects } from "@/sandbox/projects" -function sandboxConnection(name: string): ServerConnection.Any { +function sandboxConnection(name: string, projectDir?: string): ServerConnection.Any { return { displayName: name, type: "sidecar", variant: "sandbox", name, + ...(projectDir ? { projectDir } : {}), http: { url: "http://127.0.0.1:1234" }, } } @@ -20,17 +21,41 @@ const localConnection: ServerConnection.Any = { http: { url: "http://127.0.0.1:4321" }, } -function serverCtx(input: { directory?: string; worktree?: string; open?: string[]; closed?: string[] }) { +function serverCtx(input: { + directory?: string + worktree?: string + open?: string[] + closed?: string[] + project?: { id: string; worktree: string; name?: string }[] + updateFails?: boolean +}) { const opened: string[] = [] + const renamed: { projectID: string; directory: string; name: string }[] = [] return { opened, + renamed, ctx: { + sdk: { + client: { + project: { + update: async (update: { projectID: string; directory: string; name: string }) => { + if (input.updateFails) throw new Error("nope") + renamed.push(update) + }, + }, + }, + }, projects: { list: () => (input.open ?? []).map((worktree) => ({ worktree })), recentlyClosed: () => (input.closed ?? []).map((worktree) => ({ worktree })), open: (directory: string) => opened.push(directory), }, - sync: { data: { path: { directory: input.directory ?? "", worktree: input.worktree ?? "" } } }, + sync: { + data: { + path: { directory: input.directory ?? "", worktree: input.worktree ?? "" }, + project: input.project ?? [], + }, + }, }, } } @@ -81,6 +106,85 @@ test("respects a project the user closed", () => { dispose() }) +// Inside the guest the project is the mount point, so every sandbox would read +// "workspace" without this. +test("names the project after the host folder the sandbox works on", async () => { + const sandbox = serverCtx({ + directory: "/workspace", + project: [{ id: "p1", worktree: "/workspace" }], + }) + const dispose = seed(() => [sandboxConnection("work", "/home/dev/repos/codev-hub")], sandbox) + await Promise.resolve() + expect(sandbox.renamed).toEqual([{ projectID: "p1", directory: "/workspace", name: "codev-hub" }]) + dispose() +}) + +test("takes the last segment of a Windows host path", async () => { + const sandbox = serverCtx({ + directory: "/mnt/c/Users/dev/project", + project: [{ id: "p1", worktree: "/mnt/c/Users/dev/project" }], + }) + const dispose = seed(() => [sandboxConnection("work", "C:\\Users\\dev\\project")], sandbox) + await Promise.resolve() + expect(sandbox.renamed.map((update) => update.name)).toEqual(["project"]) + dispose() +}) + +test("never renames over a name the user chose", async () => { + const sandbox = serverCtx({ + directory: "/workspace", + project: [{ id: "p1", worktree: "/workspace", name: "My project" }], + }) + const dispose = seed(() => [sandboxConnection("work", "/home/dev/repos/codev-hub")], sandbox) + await Promise.resolve() + expect(sandbox.renamed).toEqual([]) + dispose() +}) + +test("does not rename when the server has no project for the directory yet", async () => { + const sandbox = serverCtx({ directory: "/workspace", project: [] }) + const dispose = seed(() => [sandboxConnection("work", "/home/dev/repos/codev-hub")], sandbox) + await Promise.resolve() + expect(sandbox.renamed).toEqual([]) + dispose() +}) + +test("renames once even when the effect re-runs", async () => { + const sandbox = serverCtx({ + directory: "/workspace", + project: [{ id: "p1", worktree: "/workspace" }], + }) + const [connections, setConnections] = createSignal([ + sandboxConnection("work", "/home/dev/repos/codev-hub"), + ]) + const dispose = seed(connections, sandbox) + await Promise.resolve() + setConnections([sandboxConnection("work", "/home/dev/repos/codev-hub")]) + await Promise.resolve() + expect(sandbox.renamed.length).toBe(1) + dispose() +}) + +test("retries the rename after a failed attempt", async () => { + const input = { + directory: "/workspace", + project: [{ id: "p1", worktree: "/workspace" }], + updateFails: true, + } + const sandbox = serverCtx(input) + const [connections, setConnections] = createSignal([ + sandboxConnection("work", "/home/dev/repos/codev-hub"), + ]) + const dispose = seed(connections, sandbox) + await Promise.resolve() + expect(sandbox.renamed).toEqual([]) + input.updateFails = false + setConnections([sandboxConnection("work", "/home/dev/repos/codev-hub")]) + await Promise.resolve() + expect(sandbox.renamed.map((update) => update.name)).toEqual(["codev-hub"]) + dispose() +}) + test("seeds a sandbox that becomes ready later", () => { const sandbox = serverCtx({ directory: "/workspace" }) const [connections, setConnections] = createSignal([localConnection]) diff --git a/packages/desktop/src/renderer/sandbox/connections.ts b/packages/desktop/src/renderer/sandbox/connections.ts index df1f91b2db..9bf345fa15 100644 --- a/packages/desktop/src/renderer/sandbox/connections.ts +++ b/packages/desktop/src/renderer/sandbox/connections.ts @@ -12,6 +12,7 @@ export function readySandboxConnections(state?: SandboxesState) { type: "sidecar" as const, variant: "sandbox" as const, name: item.config.name, + projectDir: item.config.projectDir, http: { url: item.runtime.url, username: item.runtime.username ?? undefined,