fix(adapter-utils): keep sandbox proxy sockets within Linux path limit (#10221)
## Thinking Path > - Paperclip is the open source control plane people use to manage AI agents for work > - Local process adapters can enforce network allowlists through a Unix-socket proxy inside the Linux sandbox > - The proxy socket was created under `os.tmpdir()`, which can be a deeply nested run-specific directory > - Linux Unix-domain socket paths are limited to 107 usable bytes, so deep temporary paths can be silently truncated during bind > - A truncated proxy socket resets every confined outbound connection, including the model provider API, before the agent can do work > - This pull request creates proxy sockets under the short `/tmp` path when possible and validates the path length before bind > - The benefit is reliable sandboxed egress under deep `TMPDIR` values and an explicit error instead of an opaque connection-reset storm ## Linked Issues or Issue Description ### Pre-submission checklist - [x] I searched existing open and closed issues and this is not a duplicate. - [x] I can reproduce this on upstream `master`. - [x] I confirmed the error originates in Paperclip's local-process sandbox proxy. ### What happened? Sandboxed local-process agents using `networkScope: "allowlist"` could lose all proxied egress when `TMPDIR` was deeply nested because `proxy.sock` exceeded Linux's Unix socket path limit. The truncated bind surfaced as repeated connection resets, including for provider API traffic. ### Expected behavior Proxy creation should use a Linux-safe short path and fail explicitly if no safe socket path can be created. ### Steps to reproduce 1. Set `TMPDIR` to a path long enough that `<TMPDIR>/paperclip-network-sandbox-XXXXXX/proxy.sock` exceeds 107 bytes. 2. Build or run a local-process sandbox with `networkScope: "allowlist"`. 3. Make an HTTP or HTTPS request through the generated sandbox proxy. 4. Observe connection resets from the overlong Unix socket path on affected code. ### Paperclip version or commit Upstream `master` before this change. ### Deployment mode Other — Linux local-process adapters using Bubblewrap network allowlisting. ### Installation method Built from source. ### Agent adapter(s) involved Codex and any other local-process adapter using the shared sandbox utility. ### Operating system Linux. ### Additional context GitHub search found no duplicate public issue or pull request for this defect. ## What Changed - Add a Linux Unix-socket byte-length guard that reports the unsafe path before binding. - Create network proxy temporary directories under `/tmp` first, with `os.tmpdir()` as a validated fallback. - Use the safe temporary-directory helper at both allowlist proxy creation sites while preserving trusted URL handling. - Add regression coverage that sets a deliberately deep `TMPDIR` and verifies the working socket remains within 107 bytes under `/tmp`. ## Verification - `node /srv/paperclip/home/paperclipai/paperclip/node_modules/vitest/vitest.mjs run packages/adapter-utils/src/local-process-sandbox.test.ts` — 8 passed, 4 environment-gated tests skipped. - `git diff --check origin/master...HEAD` — passed. - Greptile Review — passed after reviewing 2 files with 0 comments and no unresolved threads; this repository integration did not emit a separate numeric confidence score. - Full GitHub CI matrix — passed after one rerun of an unrelated ACPX `ENOTEMPTY` cleanup flake. - Package typecheck was attempted, but the existing shared install cannot resolve `acpx/runtime`; the failure is outside the changed files and is expected to be covered by CI's clean dependency install. ## Risks - Low risk: the change is limited to Linux sandbox proxy temporary-directory selection and validation. - Systems without a writable `/tmp` fall back to `os.tmpdir()` only when the resulting socket path is safe; otherwise startup now fails loudly instead of producing connection resets. - No schema, API, UI, migration, or documentation behavior changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, `gpt-5.3-codex`, tool-enabled coding agent with managed reasoning and code execution; context-window size is not exposed by the runtime. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
d1b9448b57
commit
2568bdecc4
|
|
@ -15,6 +15,17 @@ import { runChildProcess } from "./server-utils.js";
|
|||
|
||||
const cleanup: string[] = [];
|
||||
|
||||
async function withTmpDir<T>(tmpDir: string, run: () => Promise<T>): Promise<T> {
|
||||
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<void>((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<void>((resolve) => server.close(() => resolve()));
|
||||
|
|
|
|||
|
|
@ -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<string> {
|
||||
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<NetworkAllowlistProxy> {
|
||||
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 });
|
||||
|
|
|
|||
Loading…
Reference in New Issue