From 51ffbb380f6a862d6d9a1d43bedbdf13a634b434 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 24 Jun 2026 07:31:17 -0700 Subject: [PATCH] Exclude transient Codex home dirs from sandbox sync (#8581) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Local adapters can run agents against sandboxed execution targets by syncing the workspace and selected runtime assets into the sandbox. > - The Codex local adapter includes a managed Codex home asset so sandboxed Codex runs can use the expected auth, config, skills, and session state. > - That asset follows symlinks, which is useful for real Codex home content but unsafe for transient launcher directories. > - Transient `tmp` and `.tmp` directories can contain symlinks to large host binaries, so the sandbox archive can inline large executable targets instead of just the small home directory content. > - This pull request excludes transient Codex home directories from the sandbox home asset while preserving the required Codex home files. > - The benefit is a much smaller and more predictable sandbox setup upload without changing the runtime files Codex actually needs. ## Linked Issues or Issue Description No public issue was found for this exact sandbox archive-size bug. Bug description: - What happened: sandboxed `codex_local` runs sync the managed Codex home as a `home` asset with `followSymlinks` enabled. If transient Codex home dirs such as `tmp` or `.tmp` contain symlinks to a large host binary, the archive can inline that binary and make `Syncing home to sandbox` much larger than the managed home directory itself. - Expected behavior: sandbox setup should include the Codex home files needed for auth, config, skills, and session continuity, but should not archive transient launcher scratch directories. - Reproduction shape: create a managed Codex home with normal auth/config/skills files and a `tmp/arg0` or `.tmp` symlink to a large host executable, then start a sandboxed `codex_local` run. The home asset archive grows by the symlink target size. - Version/commit: observed on local `master` before this change. - Related public context: #5028 covers a different managed Codex home reliability issue around stale auth files; this PR addresses sandbox archive bloat from transient symlink targets. ## What Changed - Excluded `tmp` and `.tmp` from the Codex `home` asset that is uploaded for sandboxed runs. - Added regression coverage proving transient symlinked home dirs are excluded from the tar while required auth/config/skills files remain included. - Kept `followSymlinks` behavior for the rest of the Codex home asset so existing non-transient symlink behavior is preserved. ## Verification - `git diff --check` - Local PII/secret pattern scan over the committed diff - `pnpm exec vitest run packages/adapter-utils/src/sandbox-managed-runtime.test.ts` - `pnpm --filter @paperclipai/adapter-utils typecheck` - `pnpm --filter @paperclipai/adapter-codex-local typecheck` ## Risks Low risk. The exclusion is limited to transient Codex home scratch directories, and the regression test verifies the files needed in the sandbox are still archived. The main compatibility risk is if a user intentionally placed required persistent Codex state under `tmp` or `.tmp`; those paths are treated as volatile scratch space by this change. ## Model Used OpenAI Codex coding agent based on GPT-5, with shell, git, and GitHub CLI tool use. Exact hosted model build and context-window size were not exposed in the local adapter 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 --- .../src/sandbox-managed-runtime.test.ts | 91 +++++++++++++++++++ .../codex-local/src/server/execute.ts | 6 ++ 2 files changed, 97 insertions(+) diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index 76a2cd28ff..803071b754 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -429,6 +429,97 @@ describe("sandbox managed runtime", () => { await expect(readFile(path.join(remoteWorkspaceDir, "src", "main.ts"), "utf8")).resolves.toBe("x\n"); }); + it("excludes transient symlinked home dirs from the asset tar while keeping required content", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-home-tmp-")); + cleanupDirs.push(rootDir); + const localWorkspaceDir = path.join(rootDir, "local-workspace"); + const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); + const homeDir = path.join(rootDir, "codex-home"); + await mkdir(localWorkspaceDir, { recursive: true }); + + // Simulate a host Codex binary that a stale `tmp/arg0` symlink points at. + // With followSymlinks the archive would otherwise inline this whole file. + const hostBinary = path.join(rootDir, "codex-host-binary"); + const binaryMarker = "HOST_CODEX_BINARY_BYTES"; + await writeFile(hostBinary, `${binaryMarker}\n`.repeat(4096), "utf8"); + + // Required managed-home content that MUST still reach the sandbox. + await mkdir(path.join(homeDir, "skills"), { recursive: true }); + await writeFile(path.join(homeDir, "auth.json"), "{\"OPENAI_API_KEY\":\"sk-test\"}\n", "utf8"); + await writeFile(path.join(homeDir, "config.toml"), "model = \"gpt\"\n", "utf8"); + await writeFile(path.join(homeDir, "skills", "demo.md"), "skill body\n", "utf8"); + + // Transient dirs holding symlinks to the host binary (the bloat source). + await mkdir(path.join(homeDir, "tmp", "arg0"), { recursive: true }); + await mkdir(path.join(homeDir, ".tmp"), { recursive: true }); + await symlink(hostBinary, path.join(homeDir, "tmp", "arg0", "codex")); + await symlink(hostBinary, path.join(homeDir, ".tmp", "codex")); + + const uploadedTars: { remotePath: string; bytes: Buffer }[] = []; + const client: SandboxManagedRuntimeClient = { + makeDir: async (remotePath) => { + await mkdir(remotePath, { recursive: true }); + }, + writeFile: async (remotePath, bytes) => { + await mkdir(path.dirname(remotePath), { recursive: true }); + const buffer = Buffer.from(bytes); + if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer }); + await writeFile(remotePath, buffer); + }, + readFile: async (remotePath) => await readFile(remotePath), + listFiles: async () => [], + remove: async (remotePath) => { + await rm(remotePath, { recursive: true, force: true }); + }, + run: async (command) => { + await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); + }, + }; + + const prepared = await prepareSandboxManagedRuntime({ + spec: { + transport: "sandbox", + provider: "test", + sandboxId: "sandbox-1", + remoteCwd: remoteWorkspaceDir, + timeoutMs: 30_000, + apiKey: null, + }, + adapterKey: "codex", + client, + workspaceLocalDir: localWorkspaceDir, + assets: [{ + key: "home", + localDir: homeDir, + followSymlinks: true, + exclude: ["tmp", ".tmp"], + }], + }); + + const homeTar = uploadedTars.find(({ remotePath }) => path.basename(remotePath) === "home-upload.tar"); + expect(homeTar).toBeDefined(); + const members = await listTarMembers(rootDir, "home-members.tar", homeTar!.bytes); + + // Transient symlink trees must be filtered out entirely. + expect(members.some((entry) => entry === "tmp" || entry.startsWith("tmp/"))).toBe(false); + expect(members.some((entry) => entry === ".tmp" || entry.startsWith(".tmp/"))).toBe(false); + // Required managed-home content must survive. + expect(members).toContain("auth.json"); + expect(members).toContain("config.toml"); + expect(members.some((entry) => entry === "skills/demo.md")).toBe(true); + + // The host binary bytes must not have been inlined into the upload. + expect(homeTar!.bytes.includes(Buffer.from(binaryMarker))).toBe(false); + + // The extracted sandbox home keeps required content and omits the transient dirs. + await expect(readFile(path.join(prepared.assetDirs.home, "auth.json"), "utf8")) + .resolves.toBe("{\"OPENAI_API_KEY\":\"sk-test\"}\n"); + await expect(readFile(path.join(prepared.assetDirs.home, "skills", "demo.md"), "utf8")) + .resolves.toBe("skill body\n"); + await expect(lstat(path.join(prepared.assetDirs.home, "tmp"))).rejects.toMatchObject({ code: "ENOENT" }); + await expect(lstat(path.join(prepared.assetDirs.home, ".tmp"))).rejects.toMatchObject({ code: "ENOENT" }); + }); + it("emits throttled, labeled upload and restore progress with direction and percentages", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-progress-")); cleanupDirs.push(rootDir); diff --git a/packages/adapters/codex-local/src/server/execute.ts b/packages/adapters/codex-local/src/server/execute.ts index fc9e84171b..f705116d33 100644 --- a/packages/adapters/codex-local/src/server/execute.ts +++ b/packages/adapters/codex-local/src/server/execute.ts @@ -469,6 +469,12 @@ export async function execute(ctx: AdapterExecutionContext): Promise