diff --git a/packages/codev-sandbox/src/socket.test.ts b/packages/codev-sandbox/src/socket.test.ts new file mode 100644 index 0000000000..dfefafede6 --- /dev/null +++ b/packages/codev-sandbox/src/socket.test.ts @@ -0,0 +1,20 @@ +import { expect, test } from "bun:test" +import { join } from "node:path" +import { SANDBOX_SUN_PATH_MAX, sandboxSocketDir } from "./socket" + +// The failure this guards against is silent: an over-long AF_UNIX path is +// truncated at bind time, so the socket appears where nobody is listening. +test("socket paths stay inside the AF_UNIX limit for realistic sandbox names", () => { + const appData = "/Users/someone/Library/Application Support/ai.opencode.desktop.dev/sandboxes" + for (const name of ["a", "codev-work", "a-rather-long-sandbox-name-someone-might-type"]) { + for (const file of ["server.sock", "broker.sock"]) { + const path = join(sandboxSocketDir(join(appData, name)), file) + expect(path.length).toBeLessThanOrEqual(SANDBOX_SUN_PATH_MAX) + } + } +}) + +test("each sandbox gets its own socket directory", () => { + expect(sandboxSocketDir("/data/one")).not.toBe(sandboxSocketDir("/data/two")) + expect(sandboxSocketDir("/data/one")).toBe(sandboxSocketDir("/data/one")) +}) diff --git a/packages/codev-sandbox/src/socket.ts b/packages/codev-sandbox/src/socket.ts new file mode 100644 index 0000000000..bf39f3f4f4 --- /dev/null +++ b/packages/codev-sandbox/src/socket.ts @@ -0,0 +1,25 @@ +import { createHash } from "node:crypto" +import { mkdir } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +// AF_UNIX paths are capped at 104 bytes on macOS (108 on Linux) — and the +// limit is enforced by silent truncation, so an over-long path binds +// somewhere unintended and the peer then fails with ENOENT. The app's data +// directory alone is ~74 characters, so a sandbox named "codev-work" already +// overflows. Sockets therefore live under a short per-user directory keyed by +// a hash of the sandbox's data dir, which keeps them unique per sandbox and +// well clear of the limit. +export const SANDBOX_SUN_PATH_MAX = 103 + +export function sandboxSocketDir(sandboxDir: string) { + return join(tmpdir(), `codev-sbx-${createHash("sha256").update(sandboxDir).digest("hex").slice(0, 10)}`) +} + +export async function createSandboxSocketDir(sandboxDir: string) { + const dir = sandboxSocketDir(sandboxDir) + // 0700: on Linux the system temp dir is shared, so the sockets inside must + // not be reachable by other users. + await mkdir(dir, { recursive: true, mode: 0o700 }) + return dir +} diff --git a/packages/codev-sandbox/src/vz/provider.ts b/packages/codev-sandbox/src/vz/provider.ts index a432940477..ba008e82fa 100644 --- a/packages/codev-sandbox/src/vz/provider.ts +++ b/packages/codev-sandbox/src/vz/provider.ts @@ -8,6 +8,7 @@ import { runCommand, unpackRootfs } from "../bwrap/rootfs" import { readImageMarker, stageAsset, writeImageMarker } from "../image" import type { SandboxAsset } from "../manifest" import { macosSandboxAvailability } from "../policy" +import { createSandboxSocketDir } from "../socket" import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider" import { createVzHelperEventReader, type VzHelperEvent, type VzVmConfig } from "./protocol" @@ -123,6 +124,9 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { await rm(dirs.runtime, { recursive: true, force: true }) await mkdir(dirs.runtime, { recursive: true }) await mkdir(dirs.guestConfig, { recursive: true }) + // Sockets live outside the data dir: their absolute path must fit the + // AF_UNIX limit, which the data dir alone nearly exhausts. + const socketDir = await createSandboxSocketDir(dirs.base) // The broker must be listening before guest traffic flows: codev-vmm // dials runtime/broker.sock per guest connection and refuses when @@ -133,7 +137,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { const broker = egress?.routes.length ? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl, auth: egress.auth }) : null - if (broker) await broker.listenUnix(join(dirs.runtime, "broker.sock")) + if (broker) await broker.listenUnix(join(socketDir, "broker.sock")) if (egress?.guestConfig) await writeFile(join(dirs.guestConfig, "codev.json"), egress.guestConfig) const credentials = generateSandboxCredentials() @@ -147,7 +151,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { }, }), ) - const serverSocket = join(dirs.runtime, "server.sock") + const serverSocket = join(socketDir, "server.sock") const initrd = await stat(join(dirs.image, VZ_INITRD_FILE)).catch(() => null) const vmConfig: VzVmConfig = { kernel: join(dirs.image, VZ_KERNEL_FILE), @@ -164,7 +168,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider { ], forwards: [ { direction: "host-to-guest", vsockPort: VZ_SERVER_VSOCK_PORT, socketPath: serverSocket }, - { direction: "guest-to-host", vsockPort: VZ_BROKER_VSOCK_PORT, socketPath: join(dirs.runtime, "broker.sock") }, + { direction: "guest-to-host", vsockPort: VZ_BROKER_VSOCK_PORT, socketPath: join(socketDir, "broker.sock") }, ], } const configPath = join(dirs.runtime, "vm.json")