From 97bf34eb60aa85f601aa7c89437dc52c5067afeb Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 8 Sep 2026 17:24:04 -0500 Subject: [PATCH] Exclude private nested repositories before checkpoint path validation Reproduce Codex plugin-cache repositories with real Git and preserve private-runtime exclusions before validating directory entries. Co-Authored-By: Paperclip --- doc/sandbox-work-folders.md | 9 +++++---- server/src/__tests__/work-folder-transport.test.ts | 14 ++++++++++++++ server/src/services/scripts/work-folder-io.mjs | 3 +++ 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 1368031e66..9d77cd09ec 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -47,9 +47,9 @@ Sandbox runs use the operating-system user's home directory. Both legacy adapters and the native runner enter the same host-owned lifecycle before dispatch. Local execution keeps its existing workspace and home behavior. Legacy ACP proxies use a private host staging directory while agent sessions start -in the sandbox home. When managed Git launchers are present, the remote agent -starts without another login-shell profile pass, which could replace the assigned -PATH before the agent starts. For API-key Codex ACP runs, the adapter writes the explicit +in the sandbox home. After login-shell initialization, remote agents restore the +managed Git PATH ahead of paths added by the profile. This preserves both the +Git launcher and custom runtime initialization. For API-key Codex ACP runs, the adapter writes the explicit key to an owner-only login file in the staging copy; host credentials stay unchanged. Per-run GitHub launchers declare their own CommonJS package scope so warm runs inside ES-module repositories can still execute Git and GitHub CLI commands. Native runner launches carry the validated scoped paths @@ -158,7 +158,8 @@ warm checkout keeps both its completed setup and reusable caches. A complete repository checkpoint includes Git objects, refs, HEAD and index, tracked working files, and nonignored untracked files. It excludes dependencies and generated ignored caches, Git credentials/configuration, hooks, and private -runtime state. Checkpoints reject in-progress Git locks and a tree that changes +runtime state, including nested plugin repositories inside private runner caches. +Checkpoints reject in-progress Git locks and a tree that changes during scanning. The database pointer advances only after all required objects and the manifest have been saved. Restores verify ownership and hashes, then publish the restored directory atomically. Git origin configuration is recreated diff --git a/server/src/__tests__/work-folder-transport.test.ts b/server/src/__tests__/work-folder-transport.test.ts index 1b7198f2f6..abdf9a388b 100644 --- a/server/src/__tests__/work-folder-transport.test.ts +++ b/server/src/__tests__/work-folder-transport.test.ts @@ -63,4 +63,18 @@ describe("sandbox work folder transport with real Node and Git", () => { expect(paths).not.toContain(".git/config"); expect(paths.some((entry) => entry.startsWith("node_modules/"))).toBe(false); }); + it("excludes nested Git repositories in private runner state before validating their trailing slash", async () => { + const dir = await root(); + await exec("git", ["init", dir]); + const privateRepo = path.join(dir, ".paperclip-runtime/session/codex-home/.tmp/plugins"); + await mkdir(privateRepo, { recursive: true }); + await exec("git", ["init", privateRepo]); + await writeFile(path.join(privateRepo, "private-config"), "not durable"); + await writeFile(path.join(dir, "keep.txt"), "durable work"); + const listed = await exec("git", ["-C", dir, "ls-files", "--others", "--exclude-standard", "-z"]); + expect(listed.stdout).toContain(".paperclip-runtime/session/codex-home/.tmp/plugins/\0"); + const paths = (await transport.scan(dir, true)).map((entry) => entry.path); + expect(paths).toContain("keep.txt"); + expect(paths.some((entry) => entry.includes(".paperclip-runtime"))).toBe(false); + }); }); diff --git a/server/src/services/scripts/work-folder-io.mjs b/server/src/services/scripts/work-folder-io.mjs index 403d836f08..f4f3615415 100644 --- a/server/src/services/scripts/work-folder-io.mjs +++ b/server/src/services/scripts/work-folder-io.mjs @@ -113,6 +113,9 @@ function scan() { const files = execFileSync("git", ["-C", input.root, "ls-files", "--cached", "--others", "--exclude-standard", "-z"], { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 }).split("\0").filter(Boolean); for (const relative of [...new Set(files)].sort()) { + // Git reports nested repositories with a trailing slash. Private runner + // caches can contain them and must be excluded before path validation. + if (relative.split("/").includes(".paperclip-runtime")) continue; try { fs.lstatSync(full(relative)); entry(relative); } catch (error) { if (error.code !== "ENOENT") throw error; } } for (const name of children(gitDir)) {