Skip to content

Commit 2b5698d

Browse files
quickbeardclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 532eb17 commit 2b5698d

3 files changed

Lines changed: 52 additions & 3 deletions

File tree

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { expect, test } from "bun:test"
2+
import { join } from "node:path"
3+
import { SANDBOX_SUN_PATH_MAX, sandboxSocketDir } from "./socket"
4+
5+
// The failure this guards against is silent: an over-long AF_UNIX path is
6+
// truncated at bind time, so the socket appears where nobody is listening.
7+
test("socket paths stay inside the AF_UNIX limit for realistic sandbox names", () => {
8+
const appData = "/Users/someone/Library/Application Support/ai.opencode.desktop.dev/sandboxes"
9+
for (const name of ["a", "codev-work", "a-rather-long-sandbox-name-someone-might-type"]) {
10+
for (const file of ["server.sock", "broker.sock"]) {
11+
const path = join(sandboxSocketDir(join(appData, name)), file)
12+
expect(path.length).toBeLessThanOrEqual(SANDBOX_SUN_PATH_MAX)
13+
}
14+
}
15+
})
16+
17+
test("each sandbox gets its own socket directory", () => {
18+
expect(sandboxSocketDir("/data/one")).not.toBe(sandboxSocketDir("/data/two"))
19+
expect(sandboxSocketDir("/data/one")).toBe(sandboxSocketDir("/data/one"))
20+
})
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { createHash } from "node:crypto"
2+
import { mkdir } from "node:fs/promises"
3+
import { tmpdir } from "node:os"
4+
import { join } from "node:path"
5+
6+
// AF_UNIX paths are capped at 104 bytes on macOS (108 on Linux) — and the
7+
// limit is enforced by silent truncation, so an over-long path binds
8+
// somewhere unintended and the peer then fails with ENOENT. The app's data
9+
// directory alone is ~74 characters, so a sandbox named "codev-work" already
10+
// overflows. Sockets therefore live under a short per-user directory keyed by
11+
// a hash of the sandbox's data dir, which keeps them unique per sandbox and
12+
// well clear of the limit.
13+
export const SANDBOX_SUN_PATH_MAX = 103
14+
15+
export function sandboxSocketDir(sandboxDir: string) {
16+
return join(tmpdir(), `codev-sbx-${createHash("sha256").update(sandboxDir).digest("hex").slice(0, 10)}`)
17+
}
18+
19+
export async function createSandboxSocketDir(sandboxDir: string) {
20+
const dir = sandboxSocketDir(sandboxDir)
21+
// 0700: on Linux the system temp dir is shared, so the sockets inside must
22+
// not be reachable by other users.
23+
await mkdir(dir, { recursive: true, mode: 0o700 })
24+
return dir
25+
}

‎packages/codev-sandbox/src/vz/provider.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { runCommand, unpackRootfs } from "../bwrap/rootfs"
88
import { readImageMarker, stageAsset, writeImageMarker } from "../image"
99
import type { SandboxAsset } from "../manifest"
1010
import { macosSandboxAvailability } from "../policy"
11+
import { createSandboxSocketDir } from "../socket"
1112
import type { SandboxAvailability, SandboxConfig, SandboxJobOptions, SandboxProvider } from "../provider"
1213
import { createVzHelperEventReader, type VzHelperEvent, type VzVmConfig } from "./protocol"
1314

@@ -123,6 +124,9 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider {
123124
await rm(dirs.runtime, { recursive: true, force: true })
124125
await mkdir(dirs.runtime, { recursive: true })
125126
await mkdir(dirs.guestConfig, { recursive: true })
127+
// Sockets live outside the data dir: their absolute path must fit the
128+
// AF_UNIX limit, which the data dir alone nearly exhausts.
129+
const socketDir = await createSandboxSocketDir(dirs.base)
126130

127131
// The broker must be listening before guest traffic flows: codev-vmm
128132
// dials runtime/broker.sock per guest connection and refuses when
@@ -133,7 +137,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider {
133137
const broker = egress?.routes.length
134138
? createBrokerServer({ routes: egress.routes, fetchImpl: egress.fetchImpl ?? fetchImpl, auth: egress.auth })
135139
: null
136-
if (broker) await broker.listenUnix(join(dirs.runtime, "broker.sock"))
140+
if (broker) await broker.listenUnix(join(socketDir, "broker.sock"))
137141
if (egress?.guestConfig) await writeFile(join(dirs.guestConfig, "codev.json"), egress.guestConfig)
138142

139143
const credentials = generateSandboxCredentials()
@@ -147,7 +151,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider {
147151
},
148152
}),
149153
)
150-
const serverSocket = join(dirs.runtime, "server.sock")
154+
const serverSocket = join(socketDir, "server.sock")
151155
const initrd = await stat(join(dirs.image, VZ_INITRD_FILE)).catch(() => null)
152156
const vmConfig: VzVmConfig = {
153157
kernel: join(dirs.image, VZ_KERNEL_FILE),
@@ -164,7 +168,7 @@ export function createVzProvider(options: VzProviderOptions): SandboxProvider {
164168
],
165169
forwards: [
166170
{ direction: "host-to-guest", vsockPort: VZ_SERVER_VSOCK_PORT, socketPath: serverSocket },
167-
{ direction: "guest-to-host", vsockPort: VZ_BROKER_VSOCK_PORT, socketPath: join(dirs.runtime, "broker.sock") },
171+
{ direction: "guest-to-host", vsockPort: VZ_BROKER_VSOCK_PORT, socketPath: join(socketDir, "broker.sock") },
168172
],
169173
}
170174
const configPath = join(dirs.runtime, "vm.json")

0 commit comments

Comments
 (0)