diff --git a/packages/adapter-utils/src/local-process-sandbox.test.ts b/packages/adapter-utils/src/local-process-sandbox.test.ts index 36b3f8a4d3..222ef18a8d 100644 --- a/packages/adapter-utils/src/local-process-sandbox.test.ts +++ b/packages/adapter-utils/src/local-process-sandbox.test.ts @@ -15,6 +15,17 @@ import { runChildProcess } from "./server-utils.js"; const cleanup: string[] = []; +async function withTmpDir(tmpDir: string, run: () => Promise): Promise { + const previousTmpDir = process.env.TMPDIR; + process.env.TMPDIR = tmpDir; + try { + return await run(); + } finally { + if (previousTmpDir === undefined) delete process.env.TMPDIR; + else process.env.TMPDIR = previousTmpDir; + } +} + afterEach(async () => { await Promise.all(cleanup.splice(0).map((candidate) => fs.rm(candidate, { recursive: true, force: true }))); }); @@ -141,25 +152,34 @@ describe("local process sandbox", () => { expect(target.env?.HTTP_PROXY).toBeUndefined(); }); - it("forwards allowed proxy targets and rejects other hosts", async () => { + it("forwards allowed proxy targets with a deep TMPDIR and rejects other hosts", async () => { const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-proxy-")); cleanup.push(workspace); + const deepTmpDir = path.join(workspace, ...Array.from({ length: 6 }, () => "deep-temporary-directory-segment")); + await fs.mkdir(deepTmpDir, { recursive: true }); const server = http.createServer((_request, response) => response.end("allowed-response")); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); const address = server.address(); if (!address || typeof address === "string") throw new Error("Expected TCP test server address."); - const target = await buildLocalProcessSandboxSpawnTarget({ - executable: process.execPath, - args: ["-e", "process.exit(0)"], - cwd: workspace, - options: { - workspaceDir: workspace, - networkScope: "allowlist", - networkAllowlist: [`127.0.0.1:${address.port}`], - }, - }); + const target = await withTmpDir(deepTmpDir, () => + buildLocalProcessSandboxSpawnTarget({ + executable: process.execPath, + args: ["-e", "process.exit(0)"], + cwd: workspace, + options: { + workspaceDir: workspace, + filesystemScope: "workspace", + networkScope: "allowlist", + networkAllowlist: [`127.0.0.1:${address.port}`], + }, + }), + ); const delimiterIndex = target.args.indexOf("--"); const socketPath = target.args[delimiterIndex + 3]; + expect(Buffer.byteLength(path.join(deepTmpDir, "paperclip-network-sandbox-XXXXXX", "proxy.sock"))).toBeGreaterThan(107); + expect(Buffer.byteLength(socketPath)).toBeLessThanOrEqual(107); + expect(socketPath).toMatch(/^\/tmp\/paperclip-network-sandbox-/); + expect(target.args).toContain(path.dirname(socketPath)); const request = (url: string) => new Promise<{ status: number; contentType: string | null; body: string }>((resolve, reject) => { const outgoing = http.request({ socketPath, path: url, headers: { host: new URL(url).host } }, (response) => { let body = ""; @@ -394,19 +414,29 @@ function request(url) { })().catch((error) => { console.error(error); process.exit(7); }); `; try { - const result = await runChildProcess("network-sandbox-allowlist-test", process.execPath, ["-e", script], { - cwd: workspace, - env: {}, - timeoutSec: 10, - graceSec: 1, - onLog: async () => {}, - localProcessSandbox: { - workspaceDir: workspace, - networkScope: "allowlist", - networkAllowlist: [`127.0.0.1:${address.port}`], - command: process.env.PAPERCLIP_TEST_BWRAP, - }, - }); + const deepTmpDir = path.join(workspace, ...Array.from({ length: 6 }, () => "deep-temporary-directory-segment")); + await fs.mkdir(deepTmpDir, { recursive: true }); + const result = await withTmpDir(deepTmpDir, () => + runChildProcess( + "network-sandbox-allowlist-test", + process.execPath, + ["-e", script], + { + cwd: workspace, + env: {}, + timeoutSec: 10, + graceSec: 1, + onLog: async () => {}, + localProcessSandbox: { + workspaceDir: workspace, + filesystemScope: "workspace", + networkScope: "allowlist", + networkAllowlist: [`127.0.0.1:${address.port}`], + command: process.env.PAPERCLIP_TEST_BWRAP, + }, + }, + ), + ); expect(result.exitCode, result.stderr).toBe(0); } finally { await new Promise((resolve) => server.close(() => resolve())); diff --git a/packages/adapter-utils/src/local-process-sandbox.ts b/packages/adapter-utils/src/local-process-sandbox.ts index 7015d2a4ba..95f6227a90 100644 --- a/packages/adapter-utils/src/local-process-sandbox.ts +++ b/packages/adapter-utils/src/local-process-sandbox.ts @@ -68,6 +68,8 @@ const SYSTEM_READ_PATHS = [ const PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"] as const; const SANDBOX_PROXY_PORT = 31_337; +const UNIX_SOCKET_PATH_MAX_BYTES = 107; +const NETWORK_PROXY_TEMP_PREFIX = "paperclip-network-sandbox-"; function normalizeAbsolutePath(candidate: string, label: string): string { const trimmed = candidate.trim(); @@ -163,6 +165,35 @@ function isNetworkTargetAllowed(hostname: string, port: string, rules: NetworkAl return rules.some((rule) => rule.hostname === normalizedHostname && (rule.port === null || rule.port === port)); } +function assertUnixSocketPathLength(socketPath: string): void { + const pathBytes = Buffer.byteLength(socketPath); + if (pathBytes > UNIX_SOCKET_PATH_MAX_BYTES) { + throw new Error( + `Paperclip sandbox proxy socket path is ${pathBytes} bytes, exceeding the Linux limit of ${UNIX_SOCKET_PATH_MAX_BYTES}: ${socketPath}`, + ); + } +} + +async function createNetworkProxyTempDir(): Promise { + const candidates = Array.from(new Set(["/tmp", os.tmpdir()])); + let lastError: unknown; + for (const baseDir of candidates) { + try { + const tempDir = await fs.mkdtemp(path.join(baseDir, NETWORK_PROXY_TEMP_PREFIX)); + try { + assertUnixSocketPathLength(path.join(tempDir, "proxy.sock")); + return tempDir; + } catch (error) { + await fs.rm(tempDir, { recursive: true, force: true }); + lastError = error; + } + } catch (error) { + lastError = error; + } + } + throw new Error("Unable to create a Linux-safe Paperclip sandbox proxy socket directory.", { cause: lastError }); +} + function parseTrustedNetworkUrl(value: string): NetworkAllowlistRule | null { try { const parsed = new URL(value); @@ -201,6 +232,7 @@ async function startNetworkAllowlistProxy( trustedUrls: string[], socketPath: string, ): Promise { + assertUnixSocketPathLength(socketPath); const rules = [ ...allowlist.map(parseNetworkAllowlistEntry), ...trustedUrls.map(parseTrustedNetworkUrl).filter((rule): rule is NetworkAllowlistRule => rule !== null), @@ -396,7 +428,7 @@ export async function buildLocalProcessSandboxSpawnTarget(input: { } if (networkScope === "allowlist") { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-sandbox-")); + const tempDir = await createNetworkProxyTempDir(); const socketPath = path.join(tempDir, "proxy.sock"); const bridgePath = path.join(tempDir, "bridge.cjs"); await fs.writeFile(bridgePath, await createNetworkProxyBridge(), { mode: 0o500 }); @@ -419,7 +451,7 @@ export async function buildLocalProcessSandboxSpawnTarget(input: { } else { args.push("--bind", "/", "/"); if (networkScope === "allowlist") { - const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-network-sandbox-")); + const tempDir = await createNetworkProxyTempDir(); const socketPath = path.join(tempDir, "proxy.sock"); const bridgePath = path.join(tempDir, "bridge.cjs"); await fs.writeFile(bridgePath, await createNetworkProxyBridge(), { mode: 0o500 });