diff --git a/bun.lock b/bun.lock index fedcca0f72..9a05c3df09 100644 --- a/bun.lock +++ b/bun.lock @@ -170,6 +170,13 @@ "@types/bun": "catalog:", }, }, + "packages/codev-sandbox": { + "name": "@codev/sandbox", + "version": "0.0.1", + "devDependencies": { + "@types/bun": "catalog:", + }, + }, "packages/console/app": { "name": "@opencode-ai/console-app", "version": "1.18.4-8", @@ -1430,6 +1437,8 @@ "@codev/gateway": ["@codev/gateway@workspace:packages/codev-gateway"], + "@codev/sandbox": ["@codev/sandbox@workspace:packages/codev-sandbox"], + "@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="], "@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="], diff --git a/packages/codev-sandbox/README.md b/packages/codev-sandbox/README.md new file mode 100644 index 0000000000..2f2f4383db --- /dev/null +++ b/packages/codev-sandbox/README.md @@ -0,0 +1,26 @@ +# @codev/sandbox + +Platform-neutral core for the desktop sandbox: an isolated environment (VM or +container) that runs the whole codev sidecar server so model-driven tools and +skills execute away from the host. + +Phase 0 of the sandbox plan — the pieces that need no platform backend: + +- `provider.ts` — the `SandboxProvider` contract the per-platform backends + implement (`vz` on macOS via Virtualization.framework, `wsl` on Windows via a + managed distro, `bwrap` on Ubuntu). +- `controller.ts` — lifecycle state machine generalized from the desktop WSL + servers controller: runtime states, single-flight jobs, stale-start guards. +- `policy.ts` — OS version gates: hard block below macOS 12, tiered + warn-never-block on Windows. +- `manifest.ts` / `image.ts` — pinned manifest parsing and content-addressed, + SHA-256-verified staging of guest images and the mirrored WSL installer. +- `broker.ts` — the sandbox's only egress path: a host-side proxy that forwards + exclusively to configured upstreams (default: the LLM gateway). Guests have + no network device; there is no full-outbound mode. +- `boot.ts` — the guest boot contract shared with the WSL sidecar (credentials, + env, serve args, health polling). + +Later phases add the platform providers, the desktop main-process wiring, and +the `packages/app` UI. Runtime code sticks to Node APIs (no Bun-only calls) so +it can run inside the Electron main process. diff --git a/packages/codev-sandbox/package.json b/packages/codev-sandbox/package.json new file mode 100644 index 0000000000..a3cf21f42d --- /dev/null +++ b/packages/codev-sandbox/package.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/package", + "name": "@codev/sandbox", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit", + "test": "bun test" + }, + "devDependencies": { + "@types/bun": "catalog:" + }, + "exports": { + ".": "./src/index.ts" + } +} diff --git a/packages/codev-sandbox/src/boot.test.ts b/packages/codev-sandbox/src/boot.test.ts new file mode 100644 index 0000000000..f2eaedbdba --- /dev/null +++ b/packages/codev-sandbox/src/boot.test.ts @@ -0,0 +1,63 @@ +import { expect, test } from "bun:test" +import { generateSandboxCredentials, pollSandboxHealth, sandboxBootEnv, sandboxServeArgs } from "./boot" + +test("boot env mirrors the WSL sidecar contract", () => { + expect(sandboxBootEnv({ username: "codev", password: "secret" }, "/home/codev")).toEqual({ + CODEV_SERVER_USERNAME: "codev", + CODEV_SERVER_PASSWORD: "secret", + CODEV_CLIENT: "desktop", + CODEV_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + XDG_STATE_HOME: "/home/codev/.local/state", + }) +}) + +test("generated credentials are unique per boot", () => { + const first = generateSandboxCredentials() + const second = generateSandboxCredentials() + expect(first.username).toBe("codev") + expect(first.password).not.toBe(second.password) +}) + +test("serve args bind the requested host and port", () => { + expect(sandboxServeArgs({ hostname: "0.0.0.0", port: 4096 })).toEqual([ + "serve", + "--hostname", + "0.0.0.0", + "--port", + "4096", + ]) +}) + +test("stops health polling when startup settles", async () => { + const abort = new AbortController() + let checks = 0 + const polling = pollSandboxHealth( + async () => { + checks++ + return false + }, + abort.signal, + 1, + ) + + await new Promise((resolve) => setTimeout(resolve, 5)) + abort.abort() + await polling + const settled = checks + await new Promise((resolve) => setTimeout(resolve, 5)) + expect(checks).toBe(settled) +}) + +test("health polling returns once the server responds", async () => { + const abort = new AbortController() + let checks = 0 + await pollSandboxHealth( + async () => { + checks++ + return checks >= 3 + }, + abort.signal, + 1, + ) + expect(checks).toBe(3) +}) diff --git a/packages/codev-sandbox/src/boot.ts b/packages/codev-sandbox/src/boot.ts new file mode 100644 index 0000000000..4bf3484307 --- /dev/null +++ b/packages/codev-sandbox/src/boot.ts @@ -0,0 +1,45 @@ +import { randomUUID } from "node:crypto" + +export type SandboxCredentials = { + username: string + password: string +} + +export function generateSandboxCredentials(): SandboxCredentials { + return { username: "codev", password: randomUUID() } +} + +// Mirrors the WSL sidecar env contract (packages/desktop/src/main/wsl/sidecar.ts) +// so the same server bundle boots identically in every backend. +export function sandboxBootEnv(credentials: SandboxCredentials, guestHome: string): Record { + return { + CODEV_SERVER_USERNAME: credentials.username, + CODEV_SERVER_PASSWORD: credentials.password, + CODEV_CLIENT: "desktop", + CODEV_EXPERIMENTAL_DISABLE_FILEWATCHER: "true", + XDG_STATE_HOME: `${guestHome}/.local/state`, + } +} + +export function sandboxServeArgs(options: { hostname: string; port: number }) { + return ["serve", "--hostname", options.hostname, "--port", String(options.port)] +} + +export async function pollSandboxHealth(check: () => Promise, signal: AbortSignal, interval = 100) { + while (!signal.aborted) { + if (await check()) return + await abortableDelay(interval, signal) + } +} + +function abortableDelay(duration: number, signal: AbortSignal) { + return new Promise((resolve) => { + const done = () => { + clearTimeout(timeout) + signal.removeEventListener("abort", done) + resolve() + } + const timeout = setTimeout(done, duration) + signal.addEventListener("abort", done, { once: true }) + }) +} diff --git a/packages/codev-sandbox/src/broker.test.ts b/packages/codev-sandbox/src/broker.test.ts new file mode 100644 index 0000000000..4dd214d860 --- /dev/null +++ b/packages/codev-sandbox/src/broker.test.ts @@ -0,0 +1,78 @@ +import { expect, test } from "bun:test" +import { createBrokerServer, forwardableRequestHeaders, resolveBrokerTarget } from "./broker" + +const routes = [{ prefix: "/llm", upstream: "https://gateway.test/api/" }] + +test("resolves allowed prefixes onto the upstream and denies everything else", () => { + expect(resolveBrokerTarget(routes, "/llm/v1/messages?beta=true")).toBe( + "https://gateway.test/api/v1/messages?beta=true", + ) + expect(resolveBrokerTarget(routes, "/llm")).toBe("https://gateway.test/api") + expect(resolveBrokerTarget(routes, "/llmx/v1")).toBe(null) + expect(resolveBrokerTarget(routes, "/")).toBe(null) + expect(resolveBrokerTarget(routes, "/etc/passwd")).toBe(null) +}) + +test("strips hop-by-hop and host headers before forwarding", () => { + expect( + forwardableRequestHeaders({ + host: "127.0.0.1:9999", + connection: "keep-alive", + "content-length": "42", + authorization: "Bearer token", + "x-api-key": "key", + accept: undefined, + }), + ).toEqual({ authorization: "Bearer token", "x-api-key": "key" }) +}) + +test("proxies allowed requests end-to-end and rejects the rest", async () => { + const upstream = Bun.serve({ + port: 0, + fetch: async (request) => { + const url = new URL(request.url) + return Response.json({ + method: request.method, + path: `${url.pathname}${url.search}`, + authorization: request.headers.get("authorization"), + body: await request.text(), + }) + }, + }) + const broker = createBrokerServer({ + routes: [{ prefix: "/llm", upstream: `http://127.0.0.1:${upstream.port}/v1` }], + }) + const port = await broker.listen() + + const proxied = await fetch(`http://127.0.0.1:${port}/llm/messages?stream=false`, { + method: "POST", + headers: { authorization: "Bearer inside-sandbox", "content-type": "application/json" }, + body: JSON.stringify({ prompt: "hi" }), + }) + expect(proxied.status).toBe(200) + expect(await proxied.json()).toEqual({ + method: "POST", + path: "/v1/messages?stream=false", + authorization: "Bearer inside-sandbox", + body: JSON.stringify({ prompt: "hi" }), + }) + + const denied = await fetch(`http://127.0.0.1:${port}/anything-else`) + expect(denied.status).toBe(403) + expect(await denied.json()).toEqual({ error: "sandbox egress denied", path: "/anything-else" }) + + await broker.close() + upstream.stop(true) +}) + +test("reports upstream failures as 502 instead of hanging", async () => { + const broker = createBrokerServer({ + routes: [{ prefix: "/llm", upstream: "http://127.0.0.1:1/nowhere" }], + }) + const port = await broker.listen() + const response = await fetch(`http://127.0.0.1:${port}/llm/messages`) + expect(response.status).toBe(502) + const body = (await response.json()) as { error: string } + expect(body.error).toBe("sandbox egress upstream failed") + await broker.close() +}) diff --git a/packages/codev-sandbox/src/broker.ts b/packages/codev-sandbox/src/broker.ts new file mode 100644 index 0000000000..448a487e9c --- /dev/null +++ b/packages/codev-sandbox/src/broker.ts @@ -0,0 +1,97 @@ +import { createServer } from "node:http" +import { Readable } from "node:stream" + +// The broker is the sandbox's only egress path. The guest has no network +// device; a guest-side stub tunnels to this host-side server, which forwards +// exclusively to configured upstreams (default: the LLM gateway). Everything +// else is denied — there is deliberately no full-outbound mode. +export type BrokerRoute = { + prefix: string + upstream: string +} + +export function resolveBrokerTarget(routes: BrokerRoute[], requestUrl: string) { + const url = new URL(requestUrl, "http://sandbox-broker.local") + const route = routes.find( + (candidate) => url.pathname === candidate.prefix || url.pathname.startsWith(`${candidate.prefix}/`), + ) + if (!route) return null + return `${route.upstream.replace(/\/+$/, "")}${url.pathname.slice(route.prefix.length)}${url.search}` +} + +const REQUEST_HEADER_DROP = new Set([ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "host", + "content-length", +]) + +// fetch transparently decompresses, so passing these through would describe a +// body we no longer send. +const RESPONSE_HEADER_DROP = new Set(["content-encoding", "content-length", "transfer-encoding", "connection"]) + +export function forwardableRequestHeaders(headers: Record) { + return Object.fromEntries( + Object.entries(headers) + .filter(([name, value]) => value !== undefined && !REQUEST_HEADER_DROP.has(name.toLowerCase())) + .map(([name, value]) => [name, Array.isArray(value) ? value.join(", ") : (value as string)]), + ) +} + +export function forwardableResponseHeaders(headers: Headers) { + return Object.fromEntries([...headers.entries()].filter(([name]) => !RESPONSE_HEADER_DROP.has(name.toLowerCase()))) +} + +export function createBrokerServer(options: { routes: BrokerRoute[]; fetchImpl?: typeof fetch }) { + const fetchImpl = options.fetchImpl ?? fetch + const server = createServer(async (request, response) => { + const target = resolveBrokerTarget(options.routes, request.url ?? "/") + if (!target) { + response.writeHead(403, { "content-type": "application/json" }) + response.end(JSON.stringify({ error: "sandbox egress denied", path: request.url ?? "/" })) + return + } + const body = + request.method === "GET" || request.method === "HEAD" + ? undefined + : (Readable.toWeb(request) as RequestInit["body"]) + const upstream = await fetchImpl(target, { + method: request.method, + headers: forwardableRequestHeaders(request.headers), + body, + ...(body ? { duplex: "half" } : {}), + } as RequestInit).catch((error: unknown) => (error instanceof Error ? error : new Error(String(error)))) + if (upstream instanceof Error) { + response.writeHead(502, { "content-type": "application/json" }) + response.end(JSON.stringify({ error: "sandbox egress upstream failed", message: upstream.message })) + return + } + response.writeHead(upstream.status, forwardableResponseHeaders(upstream.headers)) + if (!upstream.body) { + response.end() + return + } + Readable.fromWeb(upstream.body as Parameters[0]).pipe(response) + }) + + return { + async listen(host = "127.0.0.1") { + await new Promise((resolve, reject) => { + server.once("error", reject) + server.listen(0, host, () => resolve()) + }) + const address = server.address() + if (!address || typeof address === "string") throw new Error("sandbox broker failed to bind") + return address.port + }, + async close() { + await new Promise((resolve) => server.close(() => resolve())) + }, + } +} diff --git a/packages/codev-sandbox/src/const.ts b/packages/codev-sandbox/src/const.ts new file mode 100644 index 0000000000..7f4b9901b3 --- /dev/null +++ b/packages/codev-sandbox/src/const.ts @@ -0,0 +1,10 @@ +// Base64 (matching @codev/brand and codev-gateway convention) keeps the +// storage host out of casual greps — not a secret; the decoded URLs are +// user-visible at download time. +const STORAGE_BASE = atob("aHR0cHM6Ly9uZXRtaW5kLnZpZXR0ZWwudm4vY29kZXYtc3RvcmFnZQ==") + +export const SANDBOX_MANIFEST_URL = `${STORAGE_BASE}/codev-code/sandbox/manifest.json` + +// WSL MSI installers mirrored for corporate networks where only the storage +// host is whitelisted; the Microsoft `wsl --install` path stays as fallback. +export const INSTALLERS_BASE_URL = `${STORAGE_BASE}/installers` diff --git a/packages/codev-sandbox/src/controller.test.ts b/packages/codev-sandbox/src/controller.test.ts new file mode 100644 index 0000000000..1e5e0e2834 --- /dev/null +++ b/packages/codev-sandbox/src/controller.test.ts @@ -0,0 +1,208 @@ +import { expect, test } from "bun:test" +import { createSandboxController, type SandboxController } from "./controller" +import type { RunningSandbox, SandboxConfig, SandboxProvider, SandboxRuntime } from "./provider" + +type FakeRunning = RunningSandbox & { + stopped: boolean + exit: (code: number | null, signal: NodeJS.Signals | null) => void +} + +function fakeRunning(url: string): FakeRunning { + let onExit: ((code: number | null, signal: NodeJS.Signals | null) => void) | undefined + const sandbox: FakeRunning = { + url, + username: "codev", + password: "secret", + stopped: false, + stop: () => { + sandbox.stopped = true + }, + onExit: (cb) => { + onExit = cb + }, + exit: (code, signal) => onExit?.(code, signal), + } + return sandbox +} + +function fakeProvider(overrides?: Partial): SandboxProvider { + return { + backend: "bwrap", + availability: async () => ({ supported: true, warning: null }), + create: async () => undefined, + start: async () => fakeRunning("http://127.0.0.1:4096"), + destroy: async () => undefined, + reset: async () => undefined, + ...overrides, + } +} + +const config: SandboxConfig = { id: "sandbox:default", name: "default", backend: "bwrap", projectDir: "/work" } + +function runtimeOf(controller: SandboxController, id: string) { + return controller.getState().sandboxes.find((item) => item.config.id === id)?.runtime +} + +function waitForRuntimeKind(controller: SandboxController, id: string, kind: SandboxRuntime["kind"]) { + return new Promise((resolve) => { + const current = runtimeOf(controller, id) + if (current?.kind === kind) return resolve(current) + const unsubscribe = controller.subscribe((event) => { + const runtime = event.state.sandboxes.find((item) => item.config.id === id)?.runtime + if (runtime?.kind !== kind) return + unsubscribe() + resolve(runtime) + }) + }) +} + +test("adds a sandbox, persists it, and reports the running server", async () => { + let persisted: SandboxConfig[] = [] + let created = 0 + const controller = createSandboxController({ + providers: { bwrap: fakeProvider({ create: async () => void created++ }) }, + readSandboxes: () => persisted, + writeSandboxes: (sandboxes) => { + persisted = sandboxes + }, + }) + await controller.addSandbox(config) + const runtime = await waitForRuntimeKind(controller, config.id, "ready") + expect(created).toBe(1) + expect(persisted).toEqual([config]) + expect(runtime).toEqual({ kind: "ready", url: "http://127.0.0.1:4096", username: "codev", password: "secret" }) + expect(controller.getState().job).toBe(null) +}) + +test("rejects adding the same sandbox twice", async () => { + let persisted: SandboxConfig[] = [config] + const controller = createSandboxController({ + providers: { bwrap: fakeProvider() }, + readSandboxes: () => persisted, + writeSandboxes: (sandboxes) => { + persisted = sandboxes + }, + }) + await controller.initialize() + expect(controller.addSandbox(config)).rejects.toThrow("already added") +}) + +test("reports a failed start with the provider's message", async () => { + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + start: async () => { + throw new Error("bwrap not available") + }, + }), + }, + readSandboxes: () => [config], + writeSandboxes: () => undefined, + }) + await controller.initialize() + const runtime = await waitForRuntimeKind(controller, config.id, "failed") + expect(runtime).toEqual({ kind: "failed", message: "bwrap not available" }) +}) + +test("marks the sandbox failed when the server exits", async () => { + const sandbox = fakeRunning("http://127.0.0.1:4097") + const controller = createSandboxController({ + providers: { bwrap: fakeProvider({ start: async () => sandbox }) }, + readSandboxes: () => [config], + writeSandboxes: () => undefined, + }) + await controller.initialize() + await waitForRuntimeKind(controller, config.id, "ready") + sandbox.exit(1, null) + const runtime = await waitForRuntimeKind(controller, config.id, "failed") + expect(runtime).toEqual({ kind: "failed", message: "sandbox server exited after startup (code=1 signal=null)" }) +}) + +test("ignores stale sandboxes after a rapid restart", async () => { + const resolvers: ((sandbox: RunningSandbox) => void)[] = [] + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + start: () => new Promise((resolve) => resolvers.push(resolve)), + }), + }, + readSandboxes: () => [config], + writeSandboxes: () => undefined, + }) + await controller.initialize() + const restart = controller.startSandbox(config.id) + const stale = fakeRunning("http://127.0.0.1:1111") + const fresh = fakeRunning("http://127.0.0.1:2222") + resolvers[0]!(stale) + resolvers[1]!(fresh) + await restart + const runtime = await waitForRuntimeKind(controller, config.id, "ready") + expect(stale.stopped).toBe(true) + expect(fresh.stopped).toBe(false) + expect(runtime).toEqual({ kind: "ready", url: "http://127.0.0.1:2222", username: "codev", password: "secret" }) +}) + +test("removing a sandbox stops it, destroys it, and forgets it", async () => { + let persisted: SandboxConfig[] = [] + let destroyed = 0 + const sandbox = fakeRunning("http://127.0.0.1:4098") + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + start: async () => sandbox, + destroy: async () => void destroyed++, + }), + }, + readSandboxes: () => persisted, + writeSandboxes: (sandboxes) => { + persisted = sandboxes + }, + }) + await controller.addSandbox(config) + await waitForRuntimeKind(controller, config.id, "ready") + await controller.removeSandbox(config.id) + expect(sandbox.stopped).toBe(true) + expect(destroyed).toBe(1) + expect(persisted).toEqual([]) + expect(controller.getState().sandboxes).toEqual([]) + sandbox.exit(1, null) + expect(controller.getState().sandboxes).toEqual([]) +}) + +test("reset destroys guest state and restarts the sandbox", async () => { + let resets = 0 + let starts = 0 + const controller = createSandboxController({ + providers: { + bwrap: fakeProvider({ + start: async () => { + starts++ + return fakeRunning(`http://127.0.0.1:500${starts}`) + }, + reset: async () => void resets++, + }), + }, + readSandboxes: () => [config], + writeSandboxes: () => undefined, + }) + await controller.initialize() + await waitForRuntimeKind(controller, config.id, "ready") + await controller.resetSandbox(config.id) + const runtime = await waitForRuntimeKind(controller, config.id, "ready") + expect(resets).toBe(1) + expect(starts).toBe(2) + expect(runtime).toEqual({ kind: "ready", url: "http://127.0.0.1:5002", username: "codev", password: "secret" }) +}) + +test("stopAll stops running sandboxes and invalidates pending starts", async () => { + const sandbox = fakeRunning("http://127.0.0.1:4099") + const controller = createSandboxController({ + providers: { bwrap: fakeProvider({ start: async () => sandbox }) }, + readSandboxes: () => [config], + writeSandboxes: () => undefined, + }) + await controller.initialize() + await waitForRuntimeKind(controller, config.id, "ready") + controller.stopAll() + expect(sandbox.stopped).toBe(true) +}) diff --git a/packages/codev-sandbox/src/controller.ts b/packages/codev-sandbox/src/controller.ts new file mode 100644 index 0000000000..64ca2aad9f --- /dev/null +++ b/packages/codev-sandbox/src/controller.ts @@ -0,0 +1,233 @@ +import type { RunningSandbox, SandboxBackend, SandboxConfig, SandboxItem, SandboxProvider, SandboxRuntime } from "./provider" + +export type SandboxJob = + | { kind: "create"; id: string; startedAt: number } + | { kind: "destroy"; id: string; startedAt: number } + | { kind: "reset"; id: string; startedAt: number } + +export type SandboxesState = { + sandboxes: SandboxItem[] + job: SandboxJob | null +} + +export type SandboxesEvent = { type: "state"; state: SandboxesState } + +type ControllerLogger = { + log: (message: string, meta?: unknown) => void + error: (message: string, meta?: unknown) => void +} + +type SandboxControllerOptions = { + providers: Partial> + readSandboxes: () => SandboxConfig[] + writeSandboxes: (sandboxes: SandboxConfig[]) => void + logger?: ControllerLogger +} + +export type SandboxController = ReturnType + +// Generalizes the WSL servers controller (packages/desktop/src/main/wsl/servers.ts) +// to any SandboxProvider: same runtime states, single-flight jobs, and +// stale-start-attempt guards, with WSL-specific probes left to providers. +export function createSandboxController(options: SandboxControllerOptions) { + let state: SandboxesState = { sandboxes: [], job: null } + const listeners = new Set<(event: SandboxesEvent) => void>() + const running = new Map() + const startAttempts = new Map() + let jobAbort: AbortController | undefined + const logger = options.logger + + const emit = () => { + for (const listener of listeners) listener({ type: "state", state }) + } + + const setState = (next: Partial) => { + state = { ...state, ...next } + emit() + } + + const setRuntime = (id: string, runtime: SandboxRuntime) => { + setState({ + sandboxes: state.sandboxes.map((item) => (item.config.id === id ? { ...item, runtime } : item)), + }) + } + + const requireProvider = (backend: SandboxBackend) => { + const provider = options.providers[backend] + if (!provider) throw new Error(`no sandbox provider registered for backend: ${backend}`) + return provider + } + + const beginJob = (job: SandboxJob): AbortController => { + jobAbort?.abort() + const abort = new AbortController() + jobAbort = abort + setState({ job }) + return abort + } + + const endJob = (abort: AbortController) => { + if (jobAbort !== abort) return + jobAbort = undefined + setState({ job: null }) + } + + const runJob = async (job: SandboxJob, runner: (abort: AbortController) => Promise) => { + const abort = beginJob(job) + try { + const value = await runner(abort) + endJob(abort) + return value + } catch (error) { + if (error instanceof Error && error.name === "AbortError") { + endJob(abort) + return undefined + } + const err = error instanceof Error ? error : new Error(String(error)) + endJob(abort) + throw err + } + } + + const nextStartAttempt = (id: string) => { + const next = (startAttempts.get(id) ?? 0) + 1 + startAttempts.set(id, next) + return next + } + + const invalidateStartAttempt = (id: string) => { + startAttempts.set(id, (startAttempts.get(id) ?? 0) + 1) + } + + const isCurrentStartAttempt = (id: string, attempt: number) => { + return startAttempts.get(id) === attempt && state.sandboxes.some((item) => item.config.id === id) + } + + const stopSandboxInternal = (id: string) => { + const existing = running.get(id) + if (!existing) return + running.delete(id) + try { + existing.stop() + } catch { + // ignore stop errors + } + } + + const startSandbox = async (id: string) => { + const item = state.sandboxes.find((x) => x.config.id === id) + if (!item) return + const attempt = nextStartAttempt(id) + stopSandboxInternal(id) + if (!isCurrentStartAttempt(id, attempt)) return + setRuntime(id, { kind: "starting" }) + logger?.log("sandbox starting", { id, backend: item.config.backend }) + try { + const sandbox = await requireProvider(item.config.backend).start(item.config) + if (!isCurrentStartAttempt(id, attempt)) { + try { + sandbox.stop() + } catch { + // ignore stop errors for stale sandboxes + } + return + } + running.set(id, sandbox) + setRuntime(id, { + kind: "ready", + url: sandbox.url, + username: sandbox.username, + password: sandbox.password, + }) + sandbox.onExit((code, signal) => { + if (running.get(id) !== sandbox) return + running.delete(id) + setRuntime(id, { kind: "failed", message: exitFailure(code, signal) }) + logger?.error("sandbox exited", { id, code, signal }) + }) + logger?.log("sandbox ready", { id, url: sandbox.url }) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + if (!isCurrentStartAttempt(id, attempt)) return + setRuntime(id, { kind: "failed", message }) + logger?.error("sandbox failed to start", { id, message }) + } + } + + return { + getState() { + return state + }, + subscribe(listener: (event: SandboxesEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + + async initialize() { + const persisted = options.readSandboxes() + setState({ + sandboxes: persisted.map((config) => { + const existing = state.sandboxes.find((item) => item.config.id === config.id) + return { config, runtime: existing?.runtime ?? { kind: "stopped" } } + }), + }) + for (const item of state.sandboxes) void startSandbox(item.config.id) + }, + + async addSandbox(config: SandboxConfig) { + if (state.sandboxes.some((item) => item.config.id === config.id)) { + throw new Error(`${config.id} is already added`) + } + const provider = requireProvider(config.backend) + await runJob({ kind: "create", id: config.id, startedAt: Date.now() }, async (abort) => { + await provider.create(config, { signal: abort.signal }) + }) + options.writeSandboxes([...options.readSandboxes(), config]) + setState({ sandboxes: [...state.sandboxes, { config, runtime: { kind: "starting" } }] }) + void startSandbox(config.id) + return config + }, + + async removeSandbox(id: string) { + const item = state.sandboxes.find((x) => x.config.id === id) + if (!item) return + invalidateStartAttempt(id) + stopSandboxInternal(id) + await runJob({ kind: "destroy", id, startedAt: Date.now() }, async (abort) => { + await requireProvider(item.config.backend).destroy(item.config, { signal: abort.signal }) + }) + options.writeSandboxes(options.readSandboxes().filter((config) => config.id !== id)) + setState({ sandboxes: state.sandboxes.filter((x) => x.config.id !== id) }) + }, + + async resetSandbox(id: string) { + const item = state.sandboxes.find((x) => x.config.id === id) + if (!item) return + invalidateStartAttempt(id) + stopSandboxInternal(id) + setRuntime(id, { kind: "stopped" }) + await runJob({ kind: "reset", id, startedAt: Date.now() }, async (abort) => { + await requireProvider(item.config.backend).reset(item.config, { signal: abort.signal }) + }) + await startSandbox(id) + }, + + startSandbox, + + stopAll() { + for (const item of state.sandboxes) invalidateStartAttempt(item.config.id) + for (const existing of running.values()) { + try { + existing.stop() + } catch { + // ignore + } + } + running.clear() + }, + } +} + +function exitFailure(code: number | null, signal: NodeJS.Signals | null) { + return `sandbox server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})` +} diff --git a/packages/codev-sandbox/src/image.test.ts b/packages/codev-sandbox/src/image.test.ts new file mode 100644 index 0000000000..cda4138abe --- /dev/null +++ b/packages/codev-sandbox/src/image.test.ts @@ -0,0 +1,86 @@ +import { expect, test } from "bun:test" +import { createHash } from "node:crypto" +import { mkdtemp, readdir, readFile, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { isAssetStaged, stageAsset, stagedAssetPath } from "./image" + +const payload = new TextEncoder().encode("codev sandbox image payload") + +function servePayload() { + let requests = 0 + const server = Bun.serve({ + port: 0, + fetch: () => { + requests++ + return new Response(payload) + }, + }) + return { + url: `http://127.0.0.1:${server.port}/sandbox/rootfs.tar`, + requests: () => requests, + stop: () => server.stop(true), + } +} + +test("stages an asset once, verifies it, and reuses the staged copy", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-")) + const server = servePayload() + const asset = { + url: server.url, + sha256: createHash("sha256").update(payload).digest("hex"), + bytes: payload.byteLength, + } + const staged = await stageAsset(cacheDir, asset) + expect(staged).toBe(stagedAssetPath(cacheDir, asset)) + expect(staged).toEndWith(join(asset.sha256, "rootfs.tar")) + expect(new Uint8Array(await readFile(staged))).toEqual(payload) + expect(await isAssetStaged(cacheDir, asset)).toBe(true) + + expect(await stageAsset(cacheDir, asset)).toBe(staged) + expect(server.requests()).toBe(1) + + server.stop() + await rm(cacheDir, { recursive: true, force: true }) +}) + +test("rejects a corrupted download and leaves nothing behind", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-")) + const server = servePayload() + const asset = { + url: server.url, + sha256: "b".repeat(64), + bytes: payload.byteLength, + } + expect(stageAsset(cacheDir, asset)).rejects.toThrow("failed verification") + await Bun.sleep(20) + expect(await isAssetStaged(cacheDir, asset)).toBe(false) + expect((await readdir(cacheDir)).filter((entry) => entry.startsWith("partial-"))).toEqual([]) + + server.stop() + await rm(cacheDir, { recursive: true, force: true }) +}) + +test("rejects a truncated download by byte count", async () => { + const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-")) + const server = servePayload() + const asset = { + url: server.url, + sha256: createHash("sha256").update(payload).digest("hex"), + bytes: payload.byteLength + 1, + } + expect(stageAsset(cacheDir, asset)).rejects.toThrow("failed verification") + + server.stop() + await rm(cacheDir, { recursive: true, force: true }) +}) + +test("surfaces http failures with the asset url", async () => { + const server = Bun.serve({ port: 0, fetch: () => new Response("missing", { status: 404 }) }) + const cacheDir = await mkdtemp(join(tmpdir(), "codev-sandbox-")) + const asset = { url: `http://127.0.0.1:${server.port}/gone.tar`, sha256: "c".repeat(64), bytes: 10 } + expect(stageAsset(cacheDir, asset)).rejects.toThrow("HTTP 404") + + server.stop(true) + await rm(cacheDir, { recursive: true, force: true }) +}) diff --git a/packages/codev-sandbox/src/image.ts b/packages/codev-sandbox/src/image.ts new file mode 100644 index 0000000000..ebe14241f2 --- /dev/null +++ b/packages/codev-sandbox/src/image.ts @@ -0,0 +1,57 @@ +import { createHash, randomUUID } from "node:crypto" +import { createWriteStream } from "node:fs" +import { mkdir, rename, rm, stat } from "node:fs/promises" +import { finished } from "node:stream/promises" +import { basename, dirname, join } from "node:path" +import type { SandboxAsset } from "./manifest" + +// Assets are staged content-addressed (//) so a +// manifest bump never clobbers an image a running sandbox still uses. +export function stagedAssetPath(cacheDir: string, asset: SandboxAsset) { + return join(cacheDir, asset.sha256, assetFileName(asset)) +} + +export async function isAssetStaged(cacheDir: string, asset: SandboxAsset) { + const file = await stat(stagedAssetPath(cacheDir, asset)).catch(() => null) + return !!file && file.isFile() && file.size === asset.bytes +} + +export async function stageAsset( + cacheDir: string, + asset: SandboxAsset, + opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal }, +): Promise { + if (await isAssetStaged(cacheDir, asset)) return stagedAssetPath(cacheDir, asset) + const fetchImpl = opts?.fetchImpl ?? fetch + const response = await fetchImpl(asset.url, { signal: opts?.signal }) + if (!response.ok || !response.body) + throw new Error(`sandbox asset download failed: HTTP ${response.status} for ${asset.url}`) + await mkdir(cacheDir, { recursive: true }) + const partial = join(cacheDir, `partial-${randomUUID()}`) + const hash = createHash("sha256") + const out = createWriteStream(partial) + let bytes = 0 + for await (const chunk of response.body) { + hash.update(chunk) + bytes += chunk.byteLength + if (!out.write(chunk)) await new Promise((resolve) => out.once("drain", () => resolve())) + } + out.end() + await finished(out) + const digest = hash.digest("hex") + if (bytes !== asset.bytes || digest !== asset.sha256) { + await rm(partial, { force: true }) + throw new Error( + `sandbox asset failed verification for ${asset.url}: expected ${asset.bytes} bytes sha256 ${asset.sha256}, got ${bytes} bytes sha256 ${digest}`, + ) + } + const destination = stagedAssetPath(cacheDir, asset) + await mkdir(dirname(destination), { recursive: true }) + await rename(partial, destination) + return destination +} + +function assetFileName(asset: SandboxAsset) { + const name = basename(new URL(asset.url).pathname) + return name.length ? name : "asset" +} diff --git a/packages/codev-sandbox/src/index.ts b/packages/codev-sandbox/src/index.ts new file mode 100644 index 0000000000..948b8dbfc8 --- /dev/null +++ b/packages/codev-sandbox/src/index.ts @@ -0,0 +1,8 @@ +export * from "./provider" +export * from "./policy" +export * from "./controller" +export * from "./manifest" +export * from "./image" +export * from "./broker" +export * from "./boot" +export * from "./const" diff --git a/packages/codev-sandbox/src/manifest.test.ts b/packages/codev-sandbox/src/manifest.test.ts new file mode 100644 index 0000000000..6ff5ccd97b --- /dev/null +++ b/packages/codev-sandbox/src/manifest.test.ts @@ -0,0 +1,66 @@ +import { expect, test } from "bun:test" +import { manifestImageKey, parseSandboxManifest, requireImageAsset } from "./manifest" + +const asset = (name: string) => ({ + url: `https://example.test/sandbox/${name}`, + sha256: "a".repeat(64), + bytes: 1024, +}) + +const validManifest = { + schema: 1, + imageVersion: "2026.08.1", + images: { + "vz-arm64": asset("vz-arm64.img"), + "bwrap-x64": asset("bwrap-x64.tar"), + }, + wslInstaller: { + version: "2.7.11", + recommendedMinBuild: 19045, + assets: { x64: asset("wsl.x64.msi") }, + }, +} + +test("parses a valid manifest", () => { + const manifest = parseSandboxManifest(validManifest) + expect(manifest.imageVersion).toBe("2026.08.1") + expect(requireImageAsset(manifest, "vz", "arm64").url).toContain("vz-arm64.img") + expect(manifest.wslInstaller?.recommendedMinBuild).toBe(19045) +}) + +test("normalizes sha256 digests to lowercase", () => { + const manifest = parseSandboxManifest({ + ...validManifest, + images: { "bwrap-x64": { ...asset("x"), sha256: "A".repeat(64) } }, + wslInstaller: null, + }) + expect(requireImageAsset(manifest, "bwrap", "x64").sha256).toBe("a".repeat(64)) +}) + +test("wslInstaller is optional", () => { + expect(parseSandboxManifest({ ...validManifest, wslInstaller: null }).wslInstaller).toBe(null) + const withoutInstaller = { ...validManifest } as Record + delete withoutInstaller.wslInstaller + expect(parseSandboxManifest(withoutInstaller).wslInstaller).toBe(null) +}) + +test("rejects manifests with the wrong shape", () => { + expect(() => parseSandboxManifest(null)).toThrow("must be an object") + expect(() => parseSandboxManifest({ ...validManifest, schema: 2 })).toThrow("unsupported sandbox manifest schema") + expect(() => parseSandboxManifest({ ...validManifest, imageVersion: "" })).toThrow("missing imageVersion") + expect(() => + parseSandboxManifest({ ...validManifest, images: { "vz-arm64": { ...asset("x"), url: "http://insecure" } } }), + ).toThrow("needs an https url") + expect(() => + parseSandboxManifest({ ...validManifest, images: { "vz-arm64": { ...asset("x"), sha256: "beef" } } }), + ).toThrow("needs a sha256 hex digest") + expect(() => + parseSandboxManifest({ ...validManifest, images: { "vz-arm64": { ...asset("x"), bytes: 0 } } }), + ).toThrow("needs a positive byte size") +}) + +test("names missing images by backend and arch", () => { + const manifest = parseSandboxManifest(validManifest) + expect(() => requireImageAsset(manifest, "wsl", "x64")).toThrow("no image for wsl-x64") + expect(manifestImageKey("wsl", "arm64")).toBe("wsl-arm64") +}) diff --git a/packages/codev-sandbox/src/manifest.ts b/packages/codev-sandbox/src/manifest.ts new file mode 100644 index 0000000000..192b952ab5 --- /dev/null +++ b/packages/codev-sandbox/src/manifest.ts @@ -0,0 +1,89 @@ +import type { SandboxArch, SandboxBackend } from "./provider" + +export type SandboxAsset = { + url: string + sha256: string + bytes: number +} + +export type SandboxWslInstaller = { + version: string + recommendedMinBuild: number + assets: Partial> +} + +export type SandboxManifest = { + schema: 1 + imageVersion: string + images: Partial> + wslInstaller: SandboxWslInstaller | null +} + +export function manifestImageKey(backend: SandboxBackend, arch: SandboxArch) { + return `${backend}-${arch}` +} + +export function parseSandboxManifest(input: unknown): SandboxManifest { + if (!input || typeof input !== "object") throw new Error("sandbox manifest must be an object") + const record = input as Record + if (record.schema !== 1) throw new Error(`unsupported sandbox manifest schema: ${String(record.schema)}`) + if (typeof record.imageVersion !== "string" || !record.imageVersion.length) + throw new Error("sandbox manifest is missing imageVersion") + return { + schema: 1, + imageVersion: record.imageVersion, + images: parseAssets("images", record.images), + wslInstaller: parseWslInstaller(record.wslInstaller), + } +} + +export async function fetchSandboxManifest( + url: string, + opts?: { fetchImpl?: typeof fetch; signal?: AbortSignal }, +): Promise { + const fetchImpl = opts?.fetchImpl ?? fetch + const response = await fetchImpl(url, { signal: opts?.signal }) + if (!response.ok) throw new Error(`sandbox manifest download failed: HTTP ${response.status}`) + return parseSandboxManifest(await response.json()) +} + +export function requireImageAsset(manifest: SandboxManifest, backend: SandboxBackend, arch: SandboxArch) { + const asset = manifest.images[manifestImageKey(backend, arch)] + if (!asset) throw new Error(`sandbox manifest has no image for ${manifestImageKey(backend, arch)}`) + return asset +} + +const SHA256_HEX = /^[0-9a-f]{64}$/ + +function parseAssets(label: string, value: unknown) { + if (!value || typeof value !== "object") throw new Error(`sandbox manifest is missing ${label}`) + return Object.fromEntries( + Object.entries(value as Record).map(([key, raw]) => [key, parseAsset(`${label}.${key}`, raw)]), + ) +} + +function parseAsset(label: string, value: unknown): SandboxAsset { + if (!value || typeof value !== "object") throw new Error(`sandbox asset ${label} must be an object`) + const record = value as Record + if (typeof record.url !== "string" || !record.url.startsWith("https://")) + throw new Error(`sandbox asset ${label} needs an https url`) + const sha256 = typeof record.sha256 === "string" ? record.sha256.toLowerCase() : "" + if (!SHA256_HEX.test(sha256)) throw new Error(`sandbox asset ${label} needs a sha256 hex digest`) + if (typeof record.bytes !== "number" || !Number.isInteger(record.bytes) || record.bytes <= 0) + throw new Error(`sandbox asset ${label} needs a positive byte size`) + return { url: record.url, sha256, bytes: record.bytes } +} + +function parseWslInstaller(value: unknown): SandboxWslInstaller | null { + if (value === undefined || value === null) return null + if (typeof value !== "object") throw new Error("sandbox manifest wslInstaller must be an object") + const record = value as Record + if (typeof record.version !== "string" || !record.version.length) throw new Error("wslInstaller needs a version") + if (typeof record.recommendedMinBuild !== "number" || !Number.isInteger(record.recommendedMinBuild)) + throw new Error("wslInstaller needs a recommendedMinBuild") + return { + version: record.version, + recommendedMinBuild: record.recommendedMinBuild, + assets: parseAssets("wslInstaller.assets", record.assets), + } +} diff --git a/packages/codev-sandbox/src/policy.test.ts b/packages/codev-sandbox/src/policy.test.ts new file mode 100644 index 0000000000..b777e24ffb --- /dev/null +++ b/packages/codev-sandbox/src/policy.test.ts @@ -0,0 +1,40 @@ +import { expect, test } from "bun:test" +import { + MACOS_UNSUPPORTED_MESSAGE, + WINDOWS_10_WARNING, + WINDOWS_OLD_WARNING, + linuxSandboxAvailability, + macosSandboxAvailability, + windowsBuildNumber, + windowsSandboxAvailability, +} from "./policy" + +test("blocks the sandbox below macOS 12 with an explanatory message", () => { + expect(macosSandboxAvailability("11.7.10")).toEqual({ supported: false, message: MACOS_UNSUPPORTED_MESSAGE }) + expect(macosSandboxAvailability("10.15.7")).toEqual({ supported: false, message: MACOS_UNSUPPORTED_MESSAGE }) + expect(macosSandboxAvailability("not-a-version")).toEqual({ supported: false, message: MACOS_UNSUPPORTED_MESSAGE }) +}) + +test("allows the sandbox on macOS 12 and later", () => { + expect(macosSandboxAvailability("12.0")).toEqual({ supported: true, warning: null }) + expect(macosSandboxAvailability("15.3.1")).toEqual({ supported: true, warning: null }) +}) + +test("never blocks Windows but tiers the warnings", () => { + expect(windowsSandboxAvailability(26100)).toEqual({ supported: true, warning: null }) + expect(windowsSandboxAvailability(22000)).toEqual({ supported: true, warning: null }) + expect(windowsSandboxAvailability(19045)).toEqual({ supported: true, warning: WINDOWS_10_WARNING }) + expect(windowsSandboxAvailability(19044)).toEqual({ supported: true, warning: WINDOWS_OLD_WARNING }) + expect(windowsSandboxAvailability(17763)).toEqual({ supported: true, warning: WINDOWS_OLD_WARNING }) + expect(windowsSandboxAvailability(null)).toEqual({ supported: true, warning: WINDOWS_OLD_WARNING }) +}) + +test("reads the Windows build number from os.release()", () => { + expect(windowsBuildNumber("10.0.19045")).toBe(19045) + expect(windowsBuildNumber("10.0.26100")).toBe(26100) + expect(windowsBuildNumber("garbage")).toBe(null) +}) + +test("linux is always eligible at the policy layer", () => { + expect(linuxSandboxAvailability()).toEqual({ supported: true, warning: null }) +}) diff --git a/packages/codev-sandbox/src/policy.ts b/packages/codev-sandbox/src/policy.ts new file mode 100644 index 0000000000..94229eb9f2 --- /dev/null +++ b/packages/codev-sandbox/src/policy.ts @@ -0,0 +1,45 @@ +import type { SandboxAvailability } from "./provider" + +// The sandbox depends on Virtualization.framework with virtio-fs, which needs +// macOS 12+. Below that the feature is hard-blocked: the OS API surface does +// not exist, so proceeding could only waste the user's time. +export const MACOS_MINIMUM_MAJOR = 12 + +// Windows is warn-never-block: old builds may still work, and a genuine +// incompatibility surfaces as a clear install error rather than a preflight +// refusal. 22000 is the first Windows 11 build, 19045 is Windows 10 22H2. +export const WINDOWS_11_FIRST_BUILD = 22000 +export const WINDOWS_10_22H2_BUILD = 19045 + +export const WINDOWS_10_WARNING = + "Windows 10 support for the sandbox is not fully verified. Installation will proceed, but if it fails, consider upgrading to Windows 11." + +export const WINDOWS_OLD_WARNING = + "This Windows version is out of support and may not support WSL2 at all. Installation is likely to fail; upgrading Windows is strongly recommended." + +export const MACOS_UNSUPPORTED_MESSAGE = `The sandbox requires macOS ${MACOS_MINIMUM_MAJOR} or later (it depends on Apple's Virtualization framework). Please upgrade macOS to use this feature.` + +export function macosSandboxAvailability(productVersion: string): SandboxAvailability { + const major = Number.parseInt(productVersion, 10) + if (!Number.isFinite(major)) return { supported: false, message: MACOS_UNSUPPORTED_MESSAGE } + if (major < MACOS_MINIMUM_MAJOR) return { supported: false, message: MACOS_UNSUPPORTED_MESSAGE } + return { supported: true, warning: null } +} + +export function windowsSandboxAvailability(buildNumber: number | null): SandboxAvailability { + if (buildNumber === null) return { supported: true, warning: WINDOWS_OLD_WARNING } + if (buildNumber >= WINDOWS_11_FIRST_BUILD) return { supported: true, warning: null } + if (buildNumber >= WINDOWS_10_22H2_BUILD) return { supported: true, warning: WINDOWS_10_WARNING } + return { supported: true, warning: WINDOWS_OLD_WARNING } +} + +// os.release() on Windows looks like "10.0.19045"; the third component is the +// build number that distinguishes Windows 10 tiers from Windows 11. +export function windowsBuildNumber(osRelease: string) { + const build = Number.parseInt(osRelease.split(".")[2] ?? "", 10) + return Number.isFinite(build) ? build : null +} + +export function linuxSandboxAvailability(): SandboxAvailability { + return { supported: true, warning: null } +} diff --git a/packages/codev-sandbox/src/provider.ts b/packages/codev-sandbox/src/provider.ts new file mode 100644 index 0000000000..f2150d9c79 --- /dev/null +++ b/packages/codev-sandbox/src/provider.ts @@ -0,0 +1,46 @@ +export type SandboxBackend = "vz" | "wsl" | "bwrap" + +export type SandboxArch = "x64" | "arm64" + +export type SandboxConfig = { + id: string + name: string + backend: SandboxBackend + projectDir: string +} + +export type SandboxRuntime = + | { kind: "starting" } + | { kind: "ready"; url: string; username: string | null; password: string | null } + | { kind: "failed"; message: string } + | { kind: "stopped" } + +export type SandboxItem = { + config: SandboxConfig + runtime: SandboxRuntime +} + +export type RunningSandbox = { + url: string + username: string | null + password: string + stop: () => void + onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void +} + +export type SandboxAvailability = + | { supported: true; warning: string | null } + | { supported: false; message: string } + +export type SandboxProvider = { + backend: SandboxBackend + availability(): Promise + create(config: SandboxConfig, opts?: { signal?: AbortSignal }): Promise + start(config: SandboxConfig, opts?: { signal?: AbortSignal }): Promise + destroy(config: SandboxConfig, opts?: { signal?: AbortSignal }): Promise + reset(config: SandboxConfig, opts?: { signal?: AbortSignal }): Promise +} + +export function sandboxServerId(name: string) { + return `sandbox:${name}` +} diff --git a/packages/codev-sandbox/tsconfig.json b/packages/codev-sandbox/tsconfig.json new file mode 100644 index 0000000000..5b7aaab788 --- /dev/null +++ b/packages/codev-sandbox/tsconfig.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext"], + "noUncheckedIndexedAccess": false + } +}