fix: exclude in-progress repository clones from Git staging

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 21:24:28 -05:00
1 parent 6195c98f5a
commit 5acd0bd05a
2 files changed
+30 -5

No files matched your search

@@ -44,6 +44,8 @@ const support = await getEmbeddedPostgresTestSupport();
const realGit = (await exec("which", ["git"])).stdout.trim();
const source = path.join(root, "source"), taskRoot = path.join(root, "task"), bin = path.join(root, "bin");
await Promise.all([mkdir(source), mkdir(taskRoot), mkdir(bin)]);
await exec(realGit, ["init", "--initial-branch=main", taskRoot]);
await writeFile(path.join(taskRoot, "report.txt"), "Task-owned report\n");
const git = async (...args: string[]) => (await exec(realGit, ["-C", source, ...args])).stdout.trim();
await git("init", "--initial-branch=main");
await writeFile(path.join(source, "source.txt"), "original source\n");
@@ -102,7 +104,10 @@ const support = await getEmbeddedPostgresTestSupport();
const snapshots = await readManagedWorkspaceRepositories(taskRoot);
try { expect(snapshots.map(repo => repo.path)).toEqual([readyIntent.repository.relativePath]); }
finally { await Promise.all(snapshots.map(repo => disposeGitWorkspaceSnapshot(repo.snapshot))); }
expect((await readdir(path.join(taskRoot, ".paperclip-runtime", "repository-staging"))).length).toBe(1);
expect((await readdir(path.join(taskRoot, ".paperclip-runtime", "repository-staging"))).filter(name => name.includes(".clone-")).length).toBe(1);
expect((await exec(realGit, ["-C", taskRoot, "status", "--porcelain", "--untracked-files=all"])).stdout.trim()).toBe("?? report.txt");
await exec(realGit, ["-C", taskRoot, "add", "."]);
expect((await exec(realGit, ["-C", taskRoot, "diff", "--cached", "--name-only"])).stdout.trim()).toBe("report.txt");
const baseline = await captureDirectorySnapshot(taskRoot, { exclude: [".paperclip-runtime"] });
try { expect([...baseline.entries.keys()].some(entry => entry.includes("repository-staging") || entry.includes(".clone-"))).toBe(false); }
finally { await disposeDirectorySnapshot(baseline); }
@@ -125,12 +130,17 @@ const support = await getEmbeddedPostgresTestSupport();
await rm(root, { recursive: true, force: true });
}
});
it.skipIf(process.platform === "win32")("rejects a symlinked runtime staging parent before cloning", async () => {
it.skipIf(process.platform === "win32").each(["parent", "ignore_file"])("rejects a symlinked runtime staging %s before cloning", async (unsafeEntry) => {
const root = await mkdtemp(path.join(tmpdir(), "paperclip-unsafe-repository-staging-"));
try {
const taskRoot = path.join(root, "task"), outside = path.join(root, "outside");
await mkdir(taskRoot); await mkdir(outside);
await symlink(outside, path.join(taskRoot, ".paperclip-runtime"));
if (unsafeEntry === "parent") await symlink(outside, path.join(taskRoot, ".paperclip-runtime"));
else {
const staging = path.join(taskRoot, ".paperclip-runtime", "repository-staging");
await mkdir(staging, { recursive: true });
await symlink(outside, path.join(staging, ".gitignore"));
}
const [workspace] = await db.insert(executionWorkspaces).values({ companyId, name: "Unsafe staging", cwd: taskRoot,
mode: "shared_workspace", strategyType: "task_directory" }).returning();
const [task] = await db.insert(issues).values({ companyId, title: "Unsafe staging", executionWorkspaceId: workspace.id }).returning();
@@ -138,7 +148,7 @@ const support = await getEmbeddedPostgresTestSupport();
request: { repository: { kind: "catalog", id: "123" }, requestKey: "unsafe-staging" } });
await expect(executionWorkspaceRepositoryService(db).prepareForAdmission({ companyId, issueId: task.id,
workspaceId: workspace.id, cwd: taskRoot, agentId, runId: randomUUID(), responsibleUserId: "local-board" }))
.rejects.toThrow("staging directory escapes");
.rejects.toThrow(unsafeEntry === "parent" ? "staging directory escapes" : "ignore file is not a regular file");
expect(await readdir(outside)).toEqual([]);
} finally { await rm(root, { recursive: true, force: true }); }
});
@@ -1,4 +1,4 @@
import { createHash } from "node:crypto";
import { createHash, randomUUID } from "node:crypto";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import fs from "node:fs/promises";
@@ -142,6 +142,21 @@ export function executionWorkspaceRepositoryService(db: Db) {
throw conflict("Repository staging directory escapes the task workspace");
}
}
// Self-ignore before any clone writes, including when the task directory
// is a subfolder of another checkout. Never follow a supplied ignore file.
const ignorePath = path.join(stagingParent, ".gitignore");
const ignoreEntry = await fs.lstat(ignorePath).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
throw error;
});
if (ignoreEntry && (!ignoreEntry.isFile() || ignoreEntry.isSymbolicLink())) {
throw conflict("Repository staging ignore file is not a regular file");
}
const ignoreTemporary = path.join(stagingParent, `.ignore-${randomUUID()}.tmp`);
try {
await fs.writeFile(ignoreTemporary, "*\n", { flag: "wx", mode: 0o600 });
await fs.rename(ignoreTemporary, ignorePath);
} finally { await fs.rm(ignoreTemporary, { force: true }); }
await ensureManagedRepositoriesIgnored(root);
const prepared: Array<{ id: string; cwd: string; repoUrl: string; relativePath: string; pinnedCommit: string; branchName: string | null }> = [];
for (const row of rows) {