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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@ file is the human-readable summary of what changed and why.

### Desktop / Web UI

- **Sandboxes (preview)** — the desktop app can now create isolated sandbox
servers that run the codev server inside a lightweight VM or container
(Virtualization.framework on macOS, a managed WSL distribution on Windows,
bubblewrap on Linux) with the project folder mounted in. Settings → Servers
gains an **Add sandbox** entry (name + project folder); sandboxes are listed
beside other servers with start, reset, set-default, and delete actions, and
a ready sandbox appears in the server list like any other connection. On
Windows versions below Windows 11 a compatibility warning is shown but
creation is never blocked. The sandbox image is downloaded on first use.

- **WSL setup no longer depends on reaching Microsoft's servers** — on
Windows, **Install WSL** now downloads a pinned install bundle from the
CoDev storage host (the one host whitelist-only corporate proxies already
Expand Down
4 changes: 3 additions & 1 deletion DIVERGENCES.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"./desktop-menu": "./src/desktop-menu.ts",
"./updater": "./src/updater.ts",
"./wsl/types": "./src/wsl/types.ts",
"./sandbox/types": "./src/sandbox/types.ts",
"./vite": "./vite.js",
"./index.css": "./src/index.css"
},
Expand Down
13 changes: 8 additions & 5 deletions packages/app/src/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ import { SettingsProvider, useSettings } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider, useSDK } from "@/context/sdk"
import { WslServersProvider } from "@/wsl/context"
import { SandboxesProvider } from "@/sandbox/context"
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
import LegacyLayout from "@/pages/layout"
import NewLayout from "@/pages/layout-new"
Expand Down Expand Up @@ -379,11 +380,13 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) {
>
<QueryProvider>
<WslServersProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
<SandboxesProvider>
<DialogProvider>
<MarkedProvider>
<FileComponentProvider component={File}>{props.children}</FileComponentProvider>
</MarkedProvider>
</DialogProvider>
</SandboxesProvider>
</WslServersProvider>
</QueryProvider>
</ErrorBoundary>
Expand Down
1 change: 1 addition & 0 deletions packages/app/src/components/dialog-select-server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,7 @@ export function useServerManagementController(options: { onSelect?: () => void;
async function handleRemove(key: ServerConnection.Key) {
try {
if (key.startsWith("wsl:")) await platform.wslServers?.removeServer(key)
if (key.startsWith("sandbox:")) await platform.sandboxes?.remove(key)
tabs.removeServer(key)
server.remove(key)
if ((await platform.getDefaultServer?.()) === key) {
Expand Down
13 changes: 10 additions & 3 deletions packages/app/src/components/settings-v2/servers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { useServerManagementController } from "../dialog-select-server"
import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list"
import { AddServerMenu, isWslServer, useFilteredWslServers, WslServerSettings } from "@/wsl/settings"
import { isSandboxServer, useFilteredSandboxes, SandboxServerSettings } from "@/sandbox/settings"
import "./settings-v2.css"

export const SettingsServersV2: Component = () => {
Expand All @@ -22,13 +23,18 @@ export const SettingsServersV2: Component = () => {
const controller = useServerManagementController()
const [store, setStore] = createStore({ filter: "" })
const wslServers = useFilteredWslServers(() => store.filter)
const sandboxes = useFilteredSandboxes(() => store.filter)

const showSearch = createMemo(
() => controller.sortedItems().filter((item) => !isWslServer(item)).length + wslServers().length > 1,
() =>
controller.sortedItems().filter((item) => !isWslServer(item) && !isSandboxServer(item)).length +
wslServers().length +
sandboxes().length >
1,
)

const filtered = createMemo(() => {
const items = controller.sortedItems().filter((item) => !isWslServer(item))
const items = controller.sortedItems().filter((item) => !isWslServer(item) && !isSandboxServer(item))
const query = store.filter.trim()
if (!query) return items
return fuzzysort
Expand Down Expand Up @@ -86,7 +92,7 @@ export const SettingsServersV2: Component = () => {

<div class="settings-v2-tab-body settings-v2-servers">
<Show
when={filtered().length > 0 || wslServers().length > 0}
when={filtered().length > 0 || wslServers().length > 0 || sandboxes().length > 0}
fallback={
<div class="settings-v2-servers-status">
<span>{store.filter ? language.t("palette.empty") : language.t("dialog.server.empty")}</span>
Expand All @@ -98,6 +104,7 @@ export const SettingsServersV2: Component = () => {
>
<SettingsListV2>
<WslServerSettings controller={controller} servers={wslServers} />
<SandboxServerSettings controller={controller} sandboxes={sandboxes} />
<For each={filtered()}>
{(item) => {
const key = ServerConnection.key(item)
Expand Down
4 changes: 4 additions & 0 deletions packages/app/src/context/platform.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
import type { WslServersPlatform } from "../wsl/types"
import type { SandboxesPlatform } from "../sandbox/types"
import type { UpdaterPlatform } from "../updater"

type PickerPaths = string | string[] | null
Expand Down Expand Up @@ -85,6 +86,9 @@ type PlatformBase = {
/** Manage WSL sidecar servers (Electron on Windows only) */
wslServers?: WslServersPlatform

/** Manage sandboxed sidecar servers (Electron only) */
sandboxes?: SandboxesPlatform

/** Get the preferred display backend (desktop only) */
getDisplayBackend?(): Promise<DisplayBackend | null> | DisplayBackend | null

Expand Down
6 changes: 6 additions & 0 deletions packages/app/src/context/server.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,11 @@ export namespace ServerConnection {
variant: "wsl"
distro: string
}
// Sandboxed server managed by the desktop sandbox controller (fork)
| {
variant: "sandbox"
name: string
}
) &
Base

Expand All @@ -227,6 +232,7 @@ export namespace ServerConnection {
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
if (conn.variant === "sandbox") return Key.make(`sandbox:${conn.name}`)
return Key.make("sidecar")
}
case "ssh":
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -374,6 +374,24 @@ export const dict = {
"wsl.server.menu.label": "WSL server",
"wsl.server.retryStart": "Retry start",
"wsl.server.updating": "Updating...",
"sandbox.server.add": "Add sandbox",
"sandbox.server.label": "Sandbox",
"sandbox.server.menu.label": "Sandbox",
"sandbox.server.start": "Start",
"sandbox.server.reset": "Reset",
"sandbox.status.starting": "Starting...",
"sandbox.status.ready": "Ready",
"sandbox.status.stopped": "Stopped",
"sandbox.status.failed": "Failed",
"sandbox.job.create": "Creating...",
"sandbox.job.destroy": "Removing...",
"sandbox.job.reset": "Resetting...",
"sandbox.add.namePlaceholder": "Sandbox name",
"sandbox.add.nameInvalid": "Use letters, numbers, dots, dashes, or underscores.",
"sandbox.add.projectDir": "Project folder",
"sandbox.add.choose": "Choose...",
"sandbox.add.create": "Create",
"sandbox.add.downloadHint": "The first sandbox downloads its image; on a slow connection this can take a while.",
"wsl.onboarding.step.distro": "Choose distro",
"wsl.onboarding.step.opencode": "CoDev Code",
"wsl.onboarding.checkingRuntime": "Checking WSL...",
Expand Down
18 changes: 18 additions & 0 deletions packages/app/src/i18n/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -365,6 +365,24 @@ export const dict = {
"wsl.server.menu.label": "Servidor WSL",
"wsl.server.retryStart": "Reintentar el inicio",
"wsl.server.updating": "Actualizando...",
"sandbox.server.add": "Añadir sandbox",
"sandbox.server.label": "Sandbox",
"sandbox.server.menu.label": "Sandbox",
"sandbox.server.start": "Iniciar",
"sandbox.server.reset": "Restablecer",
"sandbox.status.starting": "Iniciando...",
"sandbox.status.ready": "Listo",
"sandbox.status.stopped": "Detenido",
"sandbox.status.failed": "Falló",
"sandbox.job.create": "Creando...",
"sandbox.job.destroy": "Eliminando...",
"sandbox.job.reset": "Restableciendo...",
"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.choose": "Elegir...",
"sandbox.add.create": "Crear",
"sandbox.add.downloadHint": "El primer sandbox descarga su imagen; con una conexión lenta puede tardar un poco.",
"wsl.onboarding.step.distro": "Elegir distribución",
"wsl.onboarding.step.opencode": "CoDev Code",
"wsl.onboarding.checkingRuntime": "Comprobando WSL...",
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from
export { useCommand } from "./context/command"
export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language"
export { useWslServers } from "./wsl/context"
export { useSandboxes } from "./sandbox/context"
export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform"
export { type UpdaterPlatform, type UpdaterState } from "./updater"
export {
Expand All @@ -26,5 +27,16 @@ export {
type WslServersPlatform,
type WslServersState,
} from "./wsl/types"
export {
type SandboxAvailability,
type SandboxBackend,
type SandboxConfig,
type SandboxItem,
type SandboxJob,
type SandboxRuntime,
type SandboxesEvent,
type SandboxesPlatform,
type SandboxesState,
} from "./sandbox/types"
export { ServerConnection } from "./context/server"
export { handleNotificationClick } from "./utils/notification-click"
50 changes: 50 additions & 0 deletions packages/app/src/sandbox/context.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { queryOptions, useQuery, useQueryClient } from "@tanstack/solid-query"
import { createEffect, onCleanup } from "solid-js"
import type { SandboxAvailability, SandboxesState } from "./types"
import { usePlatform } from "../context/platform"

const sandboxesQueryKey = ["platform", "sandboxes"] as const
const sandboxAvailabilityQueryKey = ["platform", "sandboxAvailability"] as const

export const { use: useSandboxes, provider: SandboxesProvider } = createSimpleContext({
name: "Sandboxes",
init: () => {
const platform = usePlatform()
const queryClient = useQueryClient()
const state = useQuery(() => {
const api = platform.sandboxes
return queryOptions<SandboxesState>({
queryKey: sandboxesQueryKey,
queryFn: () => api!.getState(),
enabled: !!api,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
})
})
const availability = useQuery(() => {
const api = platform.sandboxes
return queryOptions<SandboxAvailability>({
queryKey: sandboxAvailabilityQueryKey,
queryFn: () => api!.availability(),
enabled: !!api,
staleTime: Number.POSITIVE_INFINITY,
gcTime: Number.POSITIVE_INFINITY,
})
})

createEffect(() => {
const api = platform.sandboxes
if (!api) return
const off = api.subscribe((event) => {
queryClient.setQueryData(sandboxesQueryKey, event.state)
})
onCleanup(off)
})

return {
state: state as typeof state & { readonly data: SandboxesState | undefined },
availability: availability as typeof availability & { readonly data: SandboxAvailability | undefined },
}
},
})
120 changes: 120 additions & 0 deletions packages/app/src/sandbox/dialog-add.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
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 { LoaderV2 } from "@opencode-ai/ui/v2/loader-v2"
import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { Show } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSandboxes } from "./context"

// Distribution and directory names are derived from the sandbox name across
// every backend, so keep it to characters that are safe in all of them.
const NAME_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/

export function DialogAddSandbox() {
const language = useLanguage()
const platform = usePlatform()
const dialog = useDialog()
const sandboxes = useSandboxes()
const api = platform.sandboxes!
const [store, setStore] = createStore({ name: "", projectDir: "", adding: false })

const availability = () => sandboxes.availability.data
const unsupportedMessage = () => {
const value = availability()
return value && !value.supported ? value.message : null
}
const warning = () => {
const value = availability()
return value?.supported ? value.warning : null
}
const nameInvalid = () => store.name.length > 0 && !NAME_PATTERN.test(store.name)
const canCreate = () =>
availability()?.supported === true && NAME_PATTERN.test(store.name) && store.projectDir.length > 0

const pickProjectDir = () => {
if (platform.platform !== "desktop") return
void platform.openDirectoryPickerDialog({ title: language.t("sandbox.add.projectDir") }).then((result) => {
if (typeof result === "string") setStore("projectDir", result)
})
}

const create = async () => {
if (!canCreate() || store.adding) return
setStore("adding", true)
try {
await api.add(store.name, store.projectDir)
dialog.close()
} catch (error) {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: error instanceof Error ? error.message : String(error),
})
} finally {
setStore("adding", false)
}
}

return (
<Dialog fit>
<DialogHeader hideClose={true}>
<DialogTitle>{language.t("sandbox.server.add")}</DialogTitle>
</DialogHeader>
<DividerV2 />
<DialogBody class="flex flex-col gap-3 pt-3">
<Show
when={availability()?.supported !== false}
fallback={<p class="text-14 text-v2-text-text-muted">{unsupportedMessage()}</p>}
>
{/* warn-never-block: the warning is shown, creation stays enabled */}
<Show when={warning()}>{(text) => <p class="text-12 text-v2-text-text-muted">{text()}</p>}</Show>
<TextInputV2
appearance="large"
placeholder={language.t("sandbox.add.namePlaceholder")}
value={store.name}
disabled={store.adding}
onInput={(event) => setStore("name", event.currentTarget.value)}
/>
<Show when={nameInvalid()}>
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.add.nameInvalid")}</p>
</Show>
<div class="flex items-center gap-2">
<TextInputV2
class="flex-1"
appearance="large"
placeholder={language.t("sandbox.add.projectDir")}
value={store.projectDir}
disabled={store.adding}
onInput={(event) => setStore("projectDir", event.currentTarget.value)}
/>
<ButtonV2 variant="neutral" disabled={store.adding} onClick={pickProjectDir}>
{language.t("sandbox.add.choose")}
</ButtonV2>
</div>
<p class="text-12 text-v2-text-text-muted">{language.t("sandbox.add.downloadHint")}</p>
</Show>
</DialogBody>
<DialogFooter>
<ButtonV2 variant="neutral" disabled={store.adding} onClick={() => dialog.close()}>
{language.t("common.cancel")}
</ButtonV2>
<Show when={availability()?.supported !== false}>
<ButtonV2
variant={store.adding ? "loading" : "contrast"}
disabled={!store.adding && !canCreate()}
onClick={() => void create()}
>
<Show when={store.adding} fallback={language.t("sandbox.add.create")}>
<LoaderV2 />
</Show>
</ButtonV2>
</Show>
</DialogFooter>
</Dialog>
)
}
Loading
Loading