Skip to content

Commit 8326e33

Browse files
Fix oversized sandbox process launch payloads (#13793)
## Thinking Path > - Paperclip manages agent work and preserves context across retries. > - Sandbox ACP runs encode their command and environment into one launch value. > - A long continuation can make that encoded value exceed Linux's exec limit. > - The launch shell then exits before the agent can initialize. > - This PR transfers large command envelopes through a private temporary file. > - The agent receives the complete environment and can start normally. ## Linked Issues or Issue Description Refs #13777. **Bug description** Sandbox tasks with long retry context fail during ACP initialization with exit 127. The streamed bridge also drops the shell error that explains the failure. **Steps to reproduce** Launch the streamed sandbox process bridge on Linux with one valid 110,000-byte environment value. Its base64 command envelope exceeds the limit on one exec argument or environment string. Daytona reports `argument list too long: env`, then exit 127. **Expected behavior** The command envelope must not make a valid child environment too large to launch. A shell startup failure must retain its diagnostic in the run log. ## What Changed - Keep envelopes up to 64 KiB on the existing launch path. Upload larger envelopes in bounded chunks inside a mode-0700 session directory. Set the final payload file to mode 0600. - Read the file without following symlinks and delete it before spawning the child. Remove incomplete uploads on failure. Both streamed and polled bridges use the same envelope. - Preserve stderr when the launch shell fails before the wrapper emits a terminal event. Emit a fixed terminal error and shutdown acknowledgement if a payload cannot be read or parsed, without exposing its contents. - Cover large environments, file permissions, payload deletion, interrupted uploads, missing or malformed payloads, and startup diagnostics. Document the transfer and cleanup behavior. ## Verification - Both large-envelope regressions fail before the fix and pass after it. - Targeted bridge, ACP engine, real-spawn, and stdin-race checks pass: 370 tests. - `pnpm --filter @paperclipai/adapter-utils typecheck` passes. - A gated live Daytona probe on the current sandbox image reproduced exit 127 with the old bridge. The fixed bridge launched the same command successfully. A second probe completed real Claude ACP initialization with a 110,000-byte context value. It did not run an agent task. All temporary sandboxes were deleted. - `pnpm -r typecheck` and `pnpm build` reach the unchanged Rust runner step and stop because this machine has no `cargo` executable. - Full CI passes on `ea816cf58d`: [run 35683853754](https://github.com/paperclipai/paperclip/actions/runs/35683853754). All 53 checks pass; two optional checks are skipped. The local full-suite run was stopped after equivalent CI suites passed; it has no final local result. - Greptile is 5/5 on `ea816cf58d`, with no unresolved review threads. The branch is mergeable. ## Risks Large envelopes require extra upload calls during startup. The temporary data stays inside the private session directory and is removed before child startup or during failure cleanup. Individual child environment values still obey the operating system's native limits. No migration or configuration change is required. ## Model Used OpenAI GPT-6 (Codex), with reasoning, repository tools, and code execution. ## 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 and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass (targeted checks; full-workspace limits described above) - [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>
1 parent c496acb commit 8326e33

3 files changed

Lines changed: 221 additions & 19 deletions

File tree

doc/acp-run-lifecycle.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ exists, so the settlement always closes it, even on an early failure.
4949

5050
## The settlement order
5151

52+
The sandbox agent bridge passes small command envelopes through its launch
53+
environment. When the encoded envelope exceeds 64 KiB, it uploads the envelope
54+
in bounded chunks to a private session directory instead. This avoids the Linux
55+
limit on one argument or environment string when retry context grows. The
56+
wrapper reads and deletes the file before spawning the agent; bridge teardown
57+
removes it if startup fails. The child's environment values remain unchanged.
58+
If the launch shell fails before the wrapper emits a protocol event, the run
59+
log retains the shell's stderr alongside the exit code.
60+
5261
The settlement sequence is the one live cleanup owner for every settled path. It
5362
claims the ledger once, makes the pure reuse decision, then runs the ordered
5463
steps:

packages/adapter-utils/src/execution-target-sandbox.test.ts

Lines changed: 137 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import http2 from "node:http2";
33
import net from "node:net";
44
import { duplexPair, type Duplex } from "node:stream";
55
import { execFile, spawn } from "node:child_process";
6-
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
6+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from "node:fs/promises";
77
import os from "node:os";
88
import path from "node:path";
99
import { promisify } from "node:util";
@@ -525,6 +525,142 @@ describe("sandbox adapter execution targets", () => {
525525
},
526526
);
527527

528+
it.each([false, true])("launches a large environment without oversized exec arguments (streamed=%s)", async (streamOutputViaSession) => {
529+
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-large-process-env-"));
530+
cleanupDirs.push(rootDir);
531+
const value = "x".repeat(110_000);
532+
const childPath = path.join(rootDir, "child.mjs");
533+
await writeFile(childPath, 'process.stdout.write(process.env.LARGE_CONTEXT ?? "missing");\n');
534+
const delegate = createLocalSandboxRunner();
535+
let checkedPrivatePayload = false;
536+
const runner = {
537+
execute: async (input: Parameters<typeof delegate.execute>[0]) => {
538+
// Linux limits each argument and environment string to 128 KiB on
539+
// systems with 4 KiB pages. Enforce that boundary on macOS too.
540+
const strings = [...(input.args ?? []), ...Object.entries(input.env ?? {}).map(([k, v]) => `${k}=${v}`)];
541+
if (strings.some((text) => Buffer.byteLength(text) >= 131_072)) {
542+
return { exitCode: 127, stdout: "", stderr: "argument list too long: env\n", timedOut: false, signal: null, pid: null, startedAt: null };
543+
}
544+
if (input.env?.PAPERCLIP_PROCESS_SESSION_DIR || input.args?.[1]?.includes("nohup node")) {
545+
const sessionRoot = path.join(runtimeRootDir, "process-sessions");
546+
const entries = await readdir(sessionRoot, { withFileTypes: true });
547+
const sessionDir = path.join(sessionRoot, entries.find((entry) => entry.isDirectory())!.name);
548+
expect((await stat(sessionDir)).mode & 0o777).toBe(0o700);
549+
expect((await stat(path.join(sessionDir, "command.b64"))).mode & 0o777).toBe(0o600);
550+
checkedPrivatePayload = true;
551+
}
552+
return delegate.execute(input);
553+
},
554+
};
555+
const runtimeRootDir = path.join(rootDir, ".paperclip-runtime");
556+
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
557+
runId: "large-process-env", adapterKey: "acpx", runtimeRootDir,
558+
target: { kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir, runner },
559+
command: process.execPath, args: [childPath], cwd: rootDir,
560+
env: { LARGE_CONTEXT: value }, timeoutSec: 10, streamOutputViaSession,
561+
});
562+
try {
563+
const result = await runProxyWithInput(bridge!.agentCommand, "", true);
564+
expect(result.code).toBe(0);
565+
expect(result.stdout).toBe(value);
566+
expect(checkedPrivatePayload).toBe(true);
567+
const sessionRoot = path.join(runtimeRootDir, "process-sessions");
568+
const sessionDirs = (await readdir(sessionRoot, { withFileTypes: true })).filter((entry) => entry.isDirectory());
569+
for (const entry of sessionDirs) {
570+
await expect(readFile(path.join(sessionRoot, entry.name, "command.b64"))).rejects.toThrow();
571+
}
572+
} finally {
573+
await bridge?.stop();
574+
}
575+
});
576+
577+
it("logs a streamed wrapper launch failure before forwarding its exit", async () => {
578+
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-wrapper-launch-error-"));
579+
cleanupDirs.push(rootDir);
580+
const delegate = createLocalSandboxRunner();
581+
const logs: string[] = [];
582+
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
583+
runId: "wrapper-launch-error", adapterKey: "acpx", runtimeRootDir: rootDir,
584+
target: {
585+
kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir,
586+
runner: { execute: async (input) => input.useSession
587+
? { exitCode: 127, stdout: "", stderr: "argument list too long: env\n", timedOut: false, signal: null, pid: null, startedAt: null }
588+
: delegate.execute(input) },
589+
},
590+
command: process.execPath, args: [], cwd: rootDir, env: {}, timeoutSec: 5,
591+
streamOutputViaSession: true,
592+
onLog: async (stream, chunk) => { if (stream === "stderr") logs.push(chunk); },
593+
});
594+
try {
595+
const result = await runProxyWithInput(bridge!.agentCommand, "", true);
596+
expect(result.code).toBe(127);
597+
expect(logs).toContain("argument list too long: env\n");
598+
} finally {
599+
await bridge?.stop();
600+
}
601+
}, 10_000);
602+
603+
it("removes an incomplete private command payload when upload fails", async () => {
604+
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-payload-upload-error-"));
605+
cleanupDirs.push(rootDir);
606+
const delegate = createLocalSandboxRunner();
607+
await expect(startAdapterExecutionTargetProcessSessionBridge({
608+
runId: "payload-upload-error", adapterKey: "acpx", runtimeRootDir: rootDir,
609+
target: {
610+
kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir,
611+
runner: { execute: async (input) => {
612+
if (input.args?.[1]?.includes("command.b64.paperclip-upload.b64") && input.args[1].includes(">>")) {
613+
throw new Error("Upload interrupted");
614+
}
615+
return delegate.execute(input);
616+
} },
617+
},
618+
command: process.execPath, args: [], cwd: rootDir,
619+
env: { LARGE_CONTEXT: "x".repeat(110_000) }, timeoutSec: 5,
620+
streamOutputViaSession: true,
621+
})).rejects.toThrow("Upload interrupted");
622+
const entries = await readdir(path.join(rootDir, "process-sessions"), { withFileTypes: true });
623+
expect(entries.filter((entry) => entry.isDirectory())).toEqual([]);
624+
});
625+
626+
it.each([
627+
{ streamed: false, corrupt: false },
628+
{ streamed: false, corrupt: true },
629+
{ streamed: true, corrupt: false },
630+
{ streamed: true, corrupt: true },
631+
])("reports payload read failures without hanging (streamed=$streamed, corrupt=$corrupt)", async ({ streamed, corrupt }) => {
632+
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-payload-read-error-"));
633+
cleanupDirs.push(rootDir);
634+
const delegate = createLocalSandboxRunner();
635+
const bridge = await startAdapterExecutionTargetProcessSessionBridge({
636+
runId: "payload-read-error", adapterKey: "acpx", runtimeRootDir: rootDir,
637+
target: {
638+
kind: "remote", transport: "sandbox", providerKey: "local-test", remoteCwd: rootDir,
639+
runner: { execute: async (input) => {
640+
if (input.env?.PAPERCLIP_PROCESS_SESSION_DIR || input.args?.[1]?.includes("nohup node")) {
641+
const sessionRoot = path.join(rootDir, "process-sessions");
642+
const entries = await readdir(sessionRoot, { withFileTypes: true });
643+
const payloadPath = path.join(sessionRoot, entries.find((entry) => entry.isDirectory())!.name, "command.b64");
644+
if (corrupt) await writeFile(payloadPath, Buffer.from("private-payload-text").toString("base64"));
645+
else await rm(payloadPath);
646+
}
647+
return delegate.execute(input);
648+
} },
649+
},
650+
command: process.execPath, args: [], cwd: rootDir,
651+
env: { LARGE_CONTEXT: "x".repeat(110_000) }, timeoutSec: 10,
652+
streamOutputViaSession: streamed,
653+
});
654+
try {
655+
const result = await runProxyWithInput(bridge!.agentCommand, "", true);
656+
expect(result.code).toBe(1);
657+
expect(result.stderr).toContain("Failed to read sandbox process session command payload.");
658+
expect(result.stderr).not.toContain("private-payload-text");
659+
} finally {
660+
await bridge?.stop();
661+
}
662+
});
663+
528664
it("test_process_session_poll_exec_parents_to_run_context", async () => {
529665
// The poll timer runs run-time execs for the whole run. Its `sandbox.exec`
530666
// span must parent to the live run span, not to the ended startup step. The

packages/adapter-utils/src/execution-target.ts

Lines changed: 75 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2036,6 +2036,38 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
20362036
// is not reclassified as ambient merely because it equals the host value.
20372037
env: sanitizeRemoteExecutionEnv(launchEnv, {}),
20382038
}), "utf8").toString("base64");
2039+
// Base64 plus JSON escaping can push an otherwise valid child environment
2040+
// past Linux's per-argument/per-variable exec limit. Keep small launches on
2041+
// the existing path; upload larger envelopes in bounded chunks instead.
2042+
const commandEnv: Record<string, string> = {};
2043+
if (commandPayload.length <= 64 * 1024) {
2044+
commandEnv.PAPERCLIP_PROCESS_SESSION_COMMAND_B64 = commandPayload;
2045+
} else {
2046+
const payloadPath = path.posix.join(sessionDir, "command.b64");
2047+
const runPayloadSetup = async (script: string) => {
2048+
const result = await runner.execute({
2049+
command: shellCommand,
2050+
args: shellCommandArgs(script),
2051+
cwd: target.remoteCwd,
2052+
timeoutMs,
2053+
bypassSession: true,
2054+
});
2055+
if (result.timedOut || result.exitCode !== 0) {
2056+
throw new Error("Failed to stage sandbox process session command payload.");
2057+
}
2058+
};
2059+
try {
2060+
// The envelope can contain credentials. Its upload intermediates stay
2061+
// inside a private session directory, and the wrapper deletes it before
2062+
// spawning the child. Teardown removes it if the wrapper cannot start.
2063+
await runPayloadSetup(`umask 077 && mkdir -m 700 ${shellQuote(sessionDir)}`);
2064+
await client.writeTextFile(payloadPath, commandPayload);
2065+
await runPayloadSetup(`chmod 600 ${shellQuote(payloadPath)}`);
2066+
} catch (error) {
2067+
await client.remove(sessionDir).catch(() => undefined);
2068+
throw error;
2069+
}
2070+
}
20392071

20402072
// Legacy poll path: background the wrapper with `nohup` and read its output
20412073
// event files with the host poll below. The streamed path launches the wrapper
@@ -2050,7 +2082,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
20502082
// I3: no numeric process identifier anywhere. Background the
20512083
// wrapper and let it go; do not capture `$!`.
20522084
`PAPERCLIP_PROCESS_SESSION_DIR=${shellQuote(sessionDir)} ` +
2053-
`PAPERCLIP_PROCESS_SESSION_COMMAND_B64=${shellQuote(commandPayload)} ` +
2085+
Object.entries(commandEnv).map(([key, value]) => `${key}=${shellQuote(value)} `).join("") +
20542086
`nohup node ${shellQuote(remoteScriptPath)} >/dev/null 2>&1 < /dev/null &`,
20552087
].join("\n"),
20562088
),
@@ -2062,8 +2094,12 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
20622094
// The wrapper launch is bridge plumbing. Keep it off the persistent
20632095
// session so it never queues behind an in-run session command.
20642096
bypassSession: true,
2097+
}).catch(async (error) => {
2098+
await client.remove(sessionDir).catch(() => undefined);
2099+
throw error;
20652100
});
20662101
if (startResult.timedOut || (startResult.exitCode ?? 1) !== 0) {
2102+
await client.remove(sessionDir).catch(() => undefined);
20672103
throw new Error(`Failed to start sandbox ACP process session bridge: ${startResult.stderr || startResult.stdout}`);
20682104
}
20692105
}
@@ -2329,16 +2365,6 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
23292365
for (const line of text.split(/\n/)) parseFrameLine(line);
23302366
};
23312367

2332-
const launchEnvForStream =
2333-
typeof input.env === "function" ? await input.env() : input.env;
2334-
const streamCommandPayload = Buffer.from(JSON.stringify({
2335-
command: input.command,
2336-
args: input.args,
2337-
cwd: input.cwd || target.remoteCwd,
2338-
// Same provenance-clean contract as the polled payload above. Preserve
2339-
// every explicit identity override even when it equals the host value.
2340-
env: sanitizeRemoteExecutionEnv(launchEnvForStream, {}),
2341-
}), "utf8").toString("base64");
23422368
await onLog(
23432369
"stdout",
23442370
`[paperclip] Starting streamed ACP process session bridge in sandbox (${target.providerKey ?? "provider"}).\n`,
@@ -2377,7 +2403,7 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
23772403
cwd: target.remoteCwd,
23782404
env: {
23792405
PAPERCLIP_PROCESS_SESSION_DIR: sessionDir,
2380-
PAPERCLIP_PROCESS_SESSION_COMMAND_B64: streamCommandPayload,
2406+
...commandEnv,
23812407
PAPERCLIP_SANDBOX_EXEC_CHANNEL: "bridge",
23822408
},
23832409
timeoutMs,
@@ -2388,6 +2414,9 @@ export async function startAdapterExecutionTargetProcessSessionBridge(input: {
23882414
});
23892415
ingestFinalText(result.stdout);
23902416
if (!sawTerminal && !stopping) {
2417+
// A shell can fail before the wrapper emits any frames. Preserve
2418+
// that launch diagnostic instead of reporting only its exit code.
2419+
if (result.stderr) await onLog("stderr", result.stderr);
23912420
deliverRemoteEvent({
23922421
type: "exit",
23932422
code: typeof result.exitCode === "number" ? result.exitCode : null,
@@ -3027,6 +3056,34 @@ await captureSessionIdentity();
30273056
void pollStdin().catch((error) => void writeEvent({ type: "error", message: error instanceof Error ? error.message : String(error) }));
30283057
`;
30293058

3059+
// Shared by both wrappers. Refuse a symlink and remove the private envelope
3060+
// before creating the child; no launch payload file belongs to the agent.
3061+
const PROCESS_SESSION_READ_COMMAND = `
3062+
let config;
3063+
try {
3064+
let commandPayload = process.env.PAPERCLIP_PROCESS_SESSION_COMMAND_B64;
3065+
if (!commandPayload) {
3066+
const payloadPath = path.posix.join(sessionDir, "command.b64");
3067+
const handle = await fs.open(payloadPath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
3068+
try {
3069+
commandPayload = await handle.readFile("utf8");
3070+
} finally {
3071+
await handle.close();
3072+
await fs.rm(payloadPath, { force: true });
3073+
}
3074+
}
3075+
config = JSON.parse(Buffer.from(commandPayload, "base64").toString("utf8"));
3076+
} catch {
3077+
// No child exists yet. Emit a terminal event on both transports and attest
3078+
// shutdown, instead of letting a detached polled wrapper fail silently.
3079+
// Parse errors can quote credential bytes, so use a fixed diagnostic.
3080+
await writeEvent({ type: "error", message: "Failed to read sandbox process session command payload." });
3081+
await writeEvent({ type: "shutdownAck" });
3082+
await new Promise((resolve) => process.stdout.write("", resolve));
3083+
process.exit(1);
3084+
}
3085+
`;
3086+
30303087
// Streamed variant: the wrapper writes each output frame as one newline-
30313088
// delimited JSON line to its stdout. The host runs this wrapper as one
30323089
// long-lived session command and reads the frames from the session log stream,
@@ -3040,8 +3097,7 @@ import { promises as fs, constants as fsConstants } from "node:fs";
30403097
import path from "node:path";
30413098
30423099
const sessionDir = process.env.PAPERCLIP_PROCESS_SESSION_DIR;
3043-
const commandPayload = process.env.PAPERCLIP_PROCESS_SESSION_COMMAND_B64;
3044-
if (!sessionDir || !commandPayload) throw new Error("Missing process session bridge env.");
3100+
if (!sessionDir) throw new Error("Missing process session bridge env.");
30453101
30463102
const stdinDir = path.posix.join(sessionDir, "stdin");
30473103
let seq = 0;
@@ -3050,7 +3106,6 @@ let shuttingDown = false;
30503106
let terminated = false;
30513107
let killTimer = null;
30523108
3053-
const config = JSON.parse(Buffer.from(commandPayload, "base64").toString("utf8"));
30543109
await fs.mkdir(stdinDir, { recursive: true });
30553110
30563111
// One newline-delimited JSON frame per event. Node keeps process.stdout writes
@@ -3078,6 +3133,8 @@ if ((await isSymbolicLink(sessionDir)) || (await isSymbolicLink(stdinDir))) {
30783133
process.exit(1);
30793134
}
30803135
3136+
${PROCESS_SESSION_READ_COMMAND}
3137+
30813138
// Hardening (I3, not containment): the wrapper's own launch env carries the
30823139
// session dir and the command payload. Scrub both keys before they reach the
30833140
// spawned child, so the child never inherits a path to its own control files.
@@ -3123,8 +3180,7 @@ import { promises as fs, constants as fsConstants } from "node:fs";
31233180
import path from "node:path";
31243181
31253182
const sessionDir = process.env.PAPERCLIP_PROCESS_SESSION_DIR;
3126-
const commandPayload = process.env.PAPERCLIP_PROCESS_SESSION_COMMAND_B64;
3127-
if (!sessionDir || !commandPayload) throw new Error("Missing process session bridge env.");
3183+
if (!sessionDir) throw new Error("Missing process session bridge env.");
31283184
31293185
const stdinDir = path.posix.join(sessionDir, "stdin");
31303186
const eventsDir = path.posix.join(sessionDir, "events");
@@ -3134,7 +3190,6 @@ let shuttingDown = false;
31343190
let terminated = false;
31353191
let killTimer = null;
31363192
3137-
const config = JSON.parse(Buffer.from(commandPayload, "base64").toString("utf8"));
31383193
await fs.mkdir(stdinDir, { recursive: true });
31393194
await fs.mkdir(eventsDir, { recursive: true });
31403195
@@ -3169,6 +3224,8 @@ if ((await isSymbolicLink(sessionDir)) || (await isSymbolicLink(stdinDir))) {
31693224
process.exit(1);
31703225
}
31713226
3227+
${PROCESS_SESSION_READ_COMMAND}
3228+
31723229
// Hardening (I3, not containment): the wrapper's own launch env carries the
31733230
// session dir and the command payload. Scrub both keys before they reach the
31743231
// spawned child, so the child never inherits a path to its own control files.

0 commit comments

Comments
 (0)