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
9 changes: 9 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions packages/codev-sandbox/README.md
Original file line number Diff line number Diff line change
@@ -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.
17 changes: 17 additions & 0 deletions packages/codev-sandbox/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
63 changes: 63 additions & 0 deletions packages/codev-sandbox/src/boot.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
45 changes: 45 additions & 0 deletions packages/codev-sandbox/src/boot.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> {
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<boolean>, signal: AbortSignal, interval = 100) {
while (!signal.aborted) {
if (await check()) return
await abortableDelay(interval, signal)
}
}

function abortableDelay(duration: number, signal: AbortSignal) {
return new Promise<void>((resolve) => {
const done = () => {
clearTimeout(timeout)
signal.removeEventListener("abort", done)
resolve()
}
const timeout = setTimeout(done, duration)
signal.addEventListener("abort", done, { once: true })
})
}
78 changes: 78 additions & 0 deletions packages/codev-sandbox/src/broker.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
97 changes: 97 additions & 0 deletions packages/codev-sandbox/src/broker.ts
Original file line number Diff line number Diff line change
@@ -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<string, string | string[] | undefined>) {
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<typeof Readable.fromWeb>[0]).pipe(response)
})

return {
async listen(host = "127.0.0.1") {
await new Promise<void>((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<void>((resolve) => server.close(() => resolve()))
},
}
}
10 changes: 10 additions & 0 deletions packages/codev-sandbox/src/const.ts
Original file line number Diff line number Diff line change
@@ -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`
Loading
Loading