From 2b5698daba5e335fb2429ebabc9d02488fb8ea74 Mon Sep 17 00:00:00 2001 From: minhn4 Date: Thu, 13 Aug 2026 14:47:07 +0700 Subject: [PATCH] fix(sandbox): keep vz sockets inside the AF_UNIX path limit A sandbox whose name pushed its socket path past 103 characters failed to start with "bind() failed ... No such file or directory". The cap is enforced by silent truncation, so the helper bound a path nobody was listening on. The app data directory alone is ~74 characters, so "codev-work" was already enough to overflow. Sockets now live in a short per-user directory keyed by a hash of the sandbox's data dir (0700, since the system temp dir is shared on Linux), which keeps them unique per sandbox and well clear of the limit. vm.json and the rest of the runtime state stay where they were. Verified: live vz e2e green (health / path api / broker), and a unit test pins socket paths under the limit for names far longer than the one that broke. Co-Authored-By: Claude Fable 5 --- packages/codev-sandbox/src/socket.test.ts | 20 ++++++++++++++++++ packages/codev-sandbox/src/socket.ts | 25 +++++++++++++++++++++++ packages/codev-sandbox/src/vz/provider.ts | 10 ++++++--- 3 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 packages/codev-sandbox/src/socket.test.ts create mode 100644 packages/codev-sandbox/src/socket.ts 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")