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
1 change: 1 addition & 0 deletions DIVERGENCES.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ from the squash message first, or use a plain merge.
| `packages/core/src/plugin/skill.ts`, `packages/opencode/src/skill/index.ts`, `packages/core/test/plugin/skill.test.ts` | Built-in skill `customize-opencode` → `customize-codev` in both registrations (plus `location` `/builtin/customize-codev.md`); descriptions rewritten to name CoDev Code; the markdown body wrapped in `rebrand(...)`; `Skill.get`/`Skill.require` resolve the old name through a `lookup` helper; test expectations updated | The skill's name and description are listed in the system prompt, so they are among the most visible brand strings the model sees; the body is upstream's config guide. The alias keeps existing configs and `skill` permission rules that name `customize-opencode` working. | Keep our name, the alias, and the `rebrand` wrapper; take upstream's `customize-opencode.md` body verbatim (it is deliberately not hand-edited for branding). |
| `packages/opencode/src/command/template/initialize.txt` | "OpenCode" → "CoDev Code" (×2) | `/init` writes this guidance into the user's `AGENTS.md`. Edited in place rather than wrapped because the file is already fork-divergent (config-file rename row) and barely churns. | Keep our two strings; take upstream's surrounding text and rebrand any new "OpenCode" mention. |
| `packages/core/package.json` | Added dependency `"@codev/brand": "workspace:*"` | The built-in skill registration rebrands its body through the fork-only `packages/codev-brand`. | Keep our dependency line; take upstream's other fields. |
| `turbo.json` | Added task `"@codev/sandbox#test"` (no deps, no outputs) | CI's `bun turbo test` only runs test tasks registered here; the fork-only sandbox package's tests must gate PRs. | Keep our task entry; take upstream's other tasks. |

## Known rebrand gaps

Expand Down
26 changes: 23 additions & 3 deletions packages/codev-sandbox/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,26 @@ Phase 0 of the sandbox plan — the pieces that need no platform backend:
- `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.
Phase 1 adds the first platform backend, `bwrap/` — the Ubuntu provider:

- `bwrap/args.ts` — the bubblewrap invocation: unshared user/pid/net/uts/ipc
namespaces (loopback-only networking), read-only rootfs binds with merged-usr
symlinks preserved, workspace rw at `/workspace`, server bundle ro at
`/opt/codev`, per-sandbox home, cleared env.
- `bwrap/bridge.ts` — how TCP crosses the guest's network namespace: unix
sockets in the shared `/run/codev` dir, with a host-side TCP→unix forwarder
and a guest-side node forward script for the server and broker paths.
- `bwrap/rootfs.ts` — image unpack (tar) and rootfs layout probing.
- `bwrap/provider.ts` — the `SandboxProvider`: availability probe (detects the
Ubuntu 23.10+ AppArmor userns restriction), create/start/destroy/reset,
health-gated startup with stderr capture.

`bwrap.integration.test.ts` exercises real bubblewrap and auto-skips where
bwrap can't run (macOS, restricted CI runners). Packaging note for later
phases: the .deb should ship an AppArmor profile permitting unprivileged user
namespaces on Ubuntu 24.04+.

Later phases add the macOS (Virtualization.framework) and Windows (managed
WSL distro) 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.
7 changes: 7 additions & 0 deletions packages/codev-sandbox/src/boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,13 @@ export function sandboxServeArgs(options: { hostname: string; port: number }) {
return ["serve", "--hostname", options.hostname, "--port", String(options.port)]
}

export async function checkSandboxHealth(url: string, credentials: SandboxCredentials, fetchImpl: typeof fetch = fetch) {
const response = await fetchImpl(`${url}/global/health`, {
headers: { authorization: `Basic ${btoa(`${credentials.username}:${credentials.password}`)}` },
}).catch(() => null)
return !!response?.ok
}

export async function pollSandboxHealth(check: () => Promise<boolean>, signal: AbortSignal, interval = 100) {
while (!signal.aborted) {
if (await check()) return
Expand Down
26 changes: 26 additions & 0 deletions packages/codev-sandbox/src/broker.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm } from "node:fs/promises"
import { tmpdir } from "node:os"
import { dirname, join } from "node:path"
import { createBrokerServer, forwardableRequestHeaders, resolveBrokerTarget } from "./broker"

const routes = [{ prefix: "/llm", upstream: "https://gateway.test/api/" }]
Expand Down Expand Up @@ -65,6 +68,29 @@ test("proxies allowed requests end-to-end and rejects the rest", async () => {
upstream.stop(true)
})

test("serves the same routes over a unix socket for netns-isolated guests", async () => {
const upstream = Bun.serve({
port: 0,
fetch: (request) => Response.json({ path: new URL(request.url).pathname }),
})
const broker = createBrokerServer({
routes: [{ prefix: "/llm", upstream: `http://127.0.0.1:${upstream.port}/v1` }],
})
const socketPath = join(await mkdtemp(join(tmpdir(), "codev-broker-")), "broker.sock")
await broker.listenUnix(socketPath)

const proxied = await fetch("http://sandbox-broker.local/llm/models", { unix: socketPath })
expect(proxied.status).toBe(200)
expect(await proxied.json()).toEqual({ path: "/v1/models" })

const denied = await fetch("http://sandbox-broker.local/direct", { unix: socketPath })
expect(denied.status).toBe(403)

await broker.close()
upstream.stop(true)
await rm(dirname(socketPath), { recursive: true, force: 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" }],
Expand Down
8 changes: 8 additions & 0 deletions packages/codev-sandbox/src/broker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,14 @@ export function createBrokerServer(options: { routes: BrokerRoute[]; fetchImpl?:
if (!address || typeof address === "string") throw new Error("sandbox broker failed to bind")
return address.port
},
// For bwrap guests the broker listens on a unix socket bind-mounted into
// the sandbox; unix sockets cross the guest's network namespace.
async listenUnix(path: string) {
await new Promise<void>((resolve, reject) => {
server.once("error", reject)
server.listen(path, () => resolve())
})
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()))
},
Expand Down
60 changes: 60 additions & 0 deletions packages/codev-sandbox/src/bwrap/args.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { expect, test } from "bun:test"
import { GUEST_WORKSPACE, bindableRootfsEntries, buildBwrapArgs } from "./args"

const spec = {
rootfsDir: "/data/default/rootfs",
rootfsEntries: [
{ name: "usr", symlinkTarget: null },
{ name: "bin", symlinkTarget: "usr/bin" },
{ name: "etc", symlinkTarget: null },
{ name: "proc", symlinkTarget: null },
{ name: "home", symlinkTarget: null },
],
projectDir: "/host/project",
bundleDir: "/host/bundle",
bridgeDir: "/data/default/bridge",
runtimeDir: "/data/default/runtime",
homeDir: "/data/default/home",
env: { CODEV_SERVER_PASSWORD: "secret", PATH: "/usr/bin" },
command: ["/bin/sh", "-c", "exec node server"],
}

test("drops rootfs entries that would shadow managed mounts", () => {
expect(bindableRootfsEntries(spec.rootfsEntries).map((entry) => entry.name)).toEqual(["usr", "bin", "etc"])
})

test("builds an isolated namespace with loopback-only networking", () => {
const args = buildBwrapArgs(spec)
expect(args).toContain("--unshare-net")
expect(args).toContain("--unshare-pid")
expect(args).toContain("--die-with-parent")
expect(args).toContain("--clearenv")
expect(args.join(" ")).toContain("--proc /proc")
})

test("binds rootfs dirs read-only and recreates merged-usr symlinks", () => {
const joined = buildBwrapArgs(spec).join(" ")
expect(joined).toContain("--ro-bind /data/default/rootfs/usr /usr")
expect(joined).toContain("--symlink usr/bin /bin")
expect(joined).not.toContain("/rootfs/proc")
expect(joined).not.toContain("/rootfs/home")
})

test("mounts the workspace rw and the bundle/bridge ro after the rootfs binds", () => {
const joined = buildBwrapArgs(spec).join(" ")
expect(joined).toContain("--bind /host/project /workspace")
expect(joined).toContain("--ro-bind /host/bundle /opt/codev")
expect(joined).toContain("--ro-bind /data/default/bridge /opt/codev-bridge")
expect(joined).toContain("--bind /data/default/runtime /run/codev")
expect(joined).toContain("--bind /data/default/home /home/codev")
expect(joined.indexOf("--bind /host/project")).toBeGreaterThan(joined.indexOf("--ro-bind /data/default/rootfs/usr"))
expect(joined).toContain(`--chdir ${GUEST_WORKSPACE}`)
})

test("clears the environment before setting the boot env", () => {
const args = buildBwrapArgs(spec)
const joined = args.join(" ")
expect(joined).toContain("--setenv CODEV_SERVER_PASSWORD secret")
expect(joined.indexOf("--setenv")).toBeGreaterThan(joined.indexOf("--clearenv"))
expect(args.slice(-4)).toEqual(["--", "/bin/sh", "-c", "exec node server"])
})
84 changes: 84 additions & 0 deletions packages/codev-sandbox/src/bwrap/args.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { join } from "node:path"

export const GUEST_WORKSPACE = "/workspace"
export const GUEST_BUNDLE = "/opt/codev"
export const GUEST_BRIDGE = "/opt/codev-bridge"
export const GUEST_RUNTIME = "/run/codev"
export const GUEST_HOME = "/home/codev"
// Fixed ports inside the guest's private network namespace — no conflicts are
// possible there, so no allocation is needed.
export const GUEST_SERVER_PORT = 4096
export const GUEST_BROKER_PORT = 8788
export const GUEST_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"

export type RootfsEntry = {
name: string
symlinkTarget: string | null
}

export type BwrapSpec = {
rootfsDir: string
rootfsEntries: RootfsEntry[]
projectDir: string
bundleDir: string
bridgeDir: string
runtimeDir: string
homeDir: string
env: Record<string, string>
command: string[]
}

// Mounts we manage ourselves; a rootfs copy of these must never shadow them.
const MANAGED_ROOTFS_ENTRIES = new Set(["proc", "dev", "sys", "tmp", "run", "home", "workspace"])

export function bindableRootfsEntries(entries: RootfsEntry[]) {
return entries.filter((entry) => !MANAGED_ROOTFS_ENTRIES.has(entry.name))
}

// --unshare-net gives the guest a network namespace whose only interface is
// loopback: the sole paths out are the unix sockets bind-mounted at /run/codev.
// Rootfs binds come first so the workspace/bundle/runtime binds mount over them.
export function buildBwrapArgs(spec: BwrapSpec) {
return [
"--die-with-parent",
"--unshare-user",
"--unshare-pid",
"--unshare-net",
"--unshare-uts",
"--unshare-ipc",
"--hostname",
"codev-sandbox",
"--proc",
"/proc",
"--dev",
"/dev",
"--tmpfs",
"/tmp",
...bindableRootfsEntries(spec.rootfsEntries).flatMap((entry) =>
entry.symlinkTarget
? ["--symlink", entry.symlinkTarget, `/${entry.name}`]
: ["--ro-bind", join(spec.rootfsDir, entry.name), `/${entry.name}`],
),
"--bind",
spec.projectDir,
GUEST_WORKSPACE,
"--ro-bind",
spec.bundleDir,
GUEST_BUNDLE,
"--ro-bind",
spec.bridgeDir,
GUEST_BRIDGE,
"--bind",
spec.runtimeDir,
GUEST_RUNTIME,
"--bind",
spec.homeDir,
GUEST_HOME,
"--chdir",
GUEST_WORKSPACE,
"--clearenv",
...Object.entries(spec.env).flatMap(([key, value]) => ["--setenv", key, value]),
"--",
...spec.command,
]
}
128 changes: 128 additions & 0 deletions packages/codev-sandbox/src/bwrap/bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { expect, test } from "bun:test"
import { mkdtemp, rm, writeFile } from "node:fs/promises"
import { connect, createServer } from "node:net"
import { tmpdir } from "node:os"
import { join } from "node:path"
import type { Server, Socket } from "node:net"
import { GUEST_FORWARD_SCRIPT, forwardTcpToUnix } from "./bridge"

function listen(server: Server, target: string | number) {
return new Promise<void>((resolve) => {
if (typeof target === "string") server.listen(target, () => resolve())
else server.listen(target, "127.0.0.1", () => resolve())
})
}

function roundtrip(socket: Socket, message: string) {
return new Promise<string>((resolve, reject) => {
socket.on("data", (data) => {
socket.end()
resolve(String(data))
})
socket.on("error", reject)
socket.write(message)
})
}

function upperEcho(socket: Socket) {
socket.on("data", (data) => socket.write(String(data).toUpperCase()))
}

test("forwards host tcp connections into a unix socket", async () => {
const dir = await mkdtemp(join(tmpdir(), "codev-bridge-"))
const socketPath = join(dir, "server.sock")
const guest = createServer(upperEcho)
await listen(guest, socketPath)

const forwarder = await forwardTcpToUnix(socketPath)
const reply = await new Promise<string>((resolve, reject) => {
const socket = connect(forwarder.port, "127.0.0.1", () => {
roundtrip(socket, "ping").then(resolve, reject)
})
})
expect(reply).toBe("PING")

await forwarder.close()
guest.close()
await rm(dir, { recursive: true, force: true })
})

test("guest forward script bridges unix-to-tcp so the full server path works end-to-end", async () => {
const dir = await mkdtemp(join(tmpdir(), "codev-bridge-"))
const socketPath = join(dir, "server.sock")
const script = join(dir, "forward.mjs")
await writeFile(script, GUEST_FORWARD_SCRIPT)

// "Guest server": a TCP echo the script dials on 127.0.0.1, as the codev
// server would be inside the namespace.
const guestServer = createServer(upperEcho)
await listen(guestServer, 0)
const guestPort = (guestServer.address() as { port: number }).port

const bridge = Bun.spawn([process.execPath, script, "unix-to-tcp", socketPath, String(guestPort)])
const until = Date.now() + 5000
while (Date.now() < until) {
const ready = await new Promise<boolean>((resolve) => {
const probe = connect(socketPath, () => {
probe.end()
resolve(true)
})
probe.on("error", () => resolve(false))
})
if (ready) break
await Bun.sleep(25)
}

// Full chain: host tcp -> forwarder -> unix socket -> guest script -> guest tcp.
const forwarder = await forwardTcpToUnix(socketPath)
const reply = await new Promise<string>((resolve, reject) => {
const socket = connect(forwarder.port, "127.0.0.1", () => {
roundtrip(socket, "hello").then(resolve, reject)
})
})
expect(reply).toBe("HELLO")

await forwarder.close()
bridge.kill()
guestServer.close()
await rm(dir, { recursive: true, force: true })
})

test("guest forward script bridges tcp-to-unix so the broker path works end-to-end", async () => {
const dir = await mkdtemp(join(tmpdir(), "codev-bridge-"))
const socketPath = join(dir, "broker.sock")
const script = join(dir, "forward.mjs")
await writeFile(script, GUEST_FORWARD_SCRIPT)

// "Host broker": listens on the unix socket the guest script dials.
const broker = createServer(upperEcho)
await listen(broker, socketPath)

// Find a free port for the script's guest-side TCP listener.
const placeholder = createServer()
await listen(placeholder, 0)
const guestPort = (placeholder.address() as { port: number }).port
await new Promise<void>((resolve) => placeholder.close(() => resolve()))

const bridge = Bun.spawn([process.execPath, script, "tcp-to-unix", String(guestPort), socketPath])
const until = Date.now() + 5000
let reply = ""
while (Date.now() < until) {
const attempt = await new Promise<string | null>((resolve) => {
const socket = connect(guestPort, "127.0.0.1", () => {
roundtrip(socket, "broker").then(resolve, () => resolve(null))
})
socket.on("error", () => resolve(null))
})
if (attempt !== null) {
reply = attempt
break
}
await Bun.sleep(25)
}
expect(reply).toBe("BROKER")

bridge.kill()
broker.close()
await rm(dir, { recursive: true, force: true })
})
Loading
Loading