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

### Desktop / Web UI

- **Sandboxes run the agent as an unprivileged user** — the server inside a
sandbox no longer runs as root: it drops to the image's ordinary `codev`
account once the guest has finished mounting, so a sandbox escape has one
less privilege to work with. Project files, the guest home, and the bundled
skills all remain fully usable. On macOS this takes effect with the next
published sandbox image; Windows sandboxes pick it up immediately.
- **Per-sandbox CPU and memory limits (macOS)** — the Add sandbox dialog gains
optional CPUs and Memory fields, applied when the sandbox VM boots; leaving
them empty keeps the previous defaults (4 CPUs, 4 GB). The fields appear
Expand Down
11 changes: 10 additions & 1 deletion packages/codev-sandbox/image/guest/codev-init
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,17 @@ for (const [key, value] of Object.entries(boot.env)) console.log("export " + key

export HOME=/home/codev
cp -Rn /etc/skel/. "$HOME"/ 2>/dev/null || true

# The server runs as the image's unprivileged codev user; PID 1 and the vsock
# bridges stay root. No uid remapping is needed: Virtualization.framework's
# virtio-fs performs host operations as the process that owns the VM and
# reports every shared file as root inside the guest, so guest ownership never
# gates workspace access — verified on macOS 15 (append, mkdir, create all
# succeed as uid 1000 and land on the host owned by the host user).
chown -R codev:codev "$HOME" 2>/dev/null

cd /workspace
node /opt/codev-guest/server-launch.mjs &
setpriv --reuid=codev --regid=codev --init-groups node /opt/codev-guest/server-launch.mjs &
SERVER=$!

# Stay PID 1: reap orphans, power off when the server dies so the helper's
Expand Down
28 changes: 27 additions & 1 deletion packages/codev-sandbox/src/wsl/provider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,14 @@ if (args[0] === "--unregister") {
}
if (args.includes("wslpath")) { process.stdout.write("/mnt/hostpath\\n"); process.exit(0) }

// Boot command: assert the script the provider built, then answer health checks.
const script = args[args.length - 1]
// Import-time distro configuration runs through the same -- /bin/sh -c shape.
if (script.includes("/etc/wsl.conf")) {
const value = read(); value.wslConf = script; write(value)
process.exit(0)
}

// Boot command: assert the script the provider built, then answer health checks.
if (!script.includes("mount --bind") || !script.includes("server-launch.mjs")) {
process.stderr.write("unexpected boot script\\n"); process.exit(2)
}
Expand Down Expand Up @@ -208,6 +214,26 @@ test("egress wires the broker bridge, guest config, and CODEV_CONFIG", async ()
await rm(dir, { recursive: true, force: true })
})

test("configures automount at import and runs the server unprivileged", async () => {
const dir = await mkdtemp(join(tmpdir(), "wsl-test-"))
const provider = createWslProvider(await preparedOptions(dir))
await provider.create(config)
const imported = JSON.parse(await Bun.file(join(dir, "state.json")).text())
// Windows has no uid to match, so the guest's own codev user owns the
// automounted drives — otherwise the dropped server cannot write them.
expect(imported.wslConf).toContain("uid=1000,gid=1000")

const sandbox = await provider.start(config)
const state = JSON.parse(await Bun.file(join(dir, "state.json")).text())
expect(state.bootScript).toContain("setpriv --reuid=codev --regid=codev --init-groups node")
// Root still does the mounts and the bridge before dropping.
expect(state.bootScript.indexOf("mount --bind")).toBeLessThan(state.bootScript.indexOf("setpriv"))

sandbox.stop()
await provider.destroy(config)
await rm(dir, { recursive: true, force: true })
})

test("routeless egress still injects the guest config but starts no broker", async () => {
const dir = await mkdtemp(join(tmpdir(), "wsl-test-"))
const provider = createWslProvider(
Expand Down
25 changes: 24 additions & 1 deletion packages/codev-sandbox/src/wsl/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,25 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
})
if (imported.code !== 0)
throw new Error(`wsl --import failed for ${distro}: ${imported.stderr.trim() || `exited ${imported.code}`}`)
// Windows has no uid to match, so the guest keeps its own codev user and
// the Windows drives are automounted as that user — without this the
// unprivileged server could not write the project directory. wsl.conf is
// read at distro boot, so write it and terminate before the first start.
const configured = await wsl([
"-d",
distro,
"-u",
"root",
"--",
"/bin/sh",
"-c",
`printf '[automount]\\noptions = "metadata,uid=1000,gid=1000"\\n' > /etc/wsl.conf`,
])
if (configured.code !== 0)
throw new Error(
`could not write /etc/wsl.conf in ${distro}: ${configured.stderr.trim() || `exited ${configured.code}`}`,
)
await wsl(["--terminate", distro])
await writeImageMarker(dirs.base, asset)
}

Expand Down Expand Up @@ -191,6 +210,7 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
? [`mount --bind ${shellQuote(await guestPath(distro, configDir))} /codev-config`]
: []),
`cp -Rn /etc/skel/. "$HOME"/ 2>/dev/null || true`,
`chown -R codev:codev "$HOME" 2>/dev/null || true`,
`if [ -f /etc/codev-guest.env ]; then . /etc/codev-guest.env; fi`,
// Guest loopback 8788 is the broker stub on every backend; on WSL it
// relays to the host's TCP broker at the NAT gateway.
Expand All @@ -201,7 +221,10 @@ export function createWslProvider(options: WslProviderOptions): SandboxProvider
]
: []),
`cd ${shellQuote(await guestPath(distro, config.projectDir))}`,
`exec node ${SANDBOX_GUEST_LAUNCHER}`,
// Root did the bind mounts and the socat bridge; the agent itself runs
// as the image's unprivileged codev user, which owns the automounted
// Windows drives per the wsl.conf written at import.
`exec setpriv --reuid=codev --regid=codev --init-groups node ${SANDBOX_GUEST_LAUNCHER}`,
].join("\n")

const child = spawnImpl(
Expand Down
Loading