fix: stage repository clones outside published workspace files

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-10-09 21:19:26 -05:00
1 parent d4bb28e0f4
commit b1d3d53edc
6 files changed
+58 -4

No files matched your search

@@ -631,6 +631,9 @@ describe("sandbox managed runtime", () => {
const localAssetsDir = path.join(rootDir, "local-assets");
const linkedAssetPath = path.join(rootDir, "linked-skill.md");
await mkdir(path.join(localWorkspaceDir, ".claude"), { recursive: true });
const cloneStaging = path.join(".paperclip-runtime", "repository-staging", "pending.clone-abc", "partial.txt");
await mkdir(path.dirname(path.join(localWorkspaceDir, cloneStaging)), { recursive: true });
await writeFile(path.join(localWorkspaceDir, cloneStaging), "Incomplete private clone");
await mkdir(localAssetsDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "local workspace\n", "utf8");
await writeFile(path.join(localWorkspaceDir, "._README.md"), "appledouble\n", "utf8");
@@ -691,6 +694,7 @@ describe("sandbox managed runtime", () => {
});
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
await expect(readFile(path.join(remoteWorkspaceDir, cloneStaging), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(remoteWorkspaceDir, "._README.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(remoteWorkspaceDir, ".claude", "settings.json"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(prepared.assetDirs.skills, "skill.md"), "utf8")).resolves.toBe("skill body\n");
@@ -719,6 +723,7 @@ describe("sandbox managed runtime", () => {
expect.stringMatching(/^config_sync:Syncing skills to environment: 100% \(\d+\.\d\/\d+\.\d MB\)$/),
expect.stringMatching(/^restore:Restoring workspace from environment: 100% \(\d+\.\d\/\d+\.\d MB\)$/),
]));
await expect(readFile(path.join(localWorkspaceDir, cloneStaging), "utf8")).resolves.toBe("Incomplete private clone");
expect(runtimeStatuses.at(-1)).toBe("finalize:Finalizing workspace");
});
@@ -1245,6 +1245,7 @@ export async function prepareSandboxManagedRuntime(input: {
}
const gitIgnoredExcludes = directoryIgnore?.kind === "git" ? directoryIgnore.ignoredPaths : undefined;
const workspaceArchiveExclude = mergeExcludes(
[".paperclip-runtime"],
input.workspaceFileMode === "all" ? [] : SANDBOX_WORKSPACE_HEAVY_DIR_EXCLUDES,
input.workspaceFileMode === "all" ? [] : [...GIT_ARCHIVE_EXCLUDES],
input.workspaceExclude,
+1 -1
View File
@@ -1090,7 +1090,7 @@ async function isLocalGitRepositoryRoot(localDir: string): Promise<boolean> {
}
}
async function listLocalProjectRepositories(localDir: string): Promise<string[]> {
export async function listLocalProjectRepositories(localDir: string): Promise<string[]> {
const root = path.join(localDir, PROJECT_REPOSITORIES_DIR);
const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => {
if (error.code === "ENOENT") return null;
@@ -2,7 +2,7 @@ import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS } from "@paperclipai/db";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { randomUUID } from "node:crypto";
import { mkdtemp, rm, mkdir, writeFile, readFile, realpath, open, lstat } from "node:fs/promises";
import { mkdtemp, rm, mkdir, writeFile, readFile, realpath, open, lstat, symlink, readdir } from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import { constants as fsConstants } from "node:fs";
@@ -11,6 +11,9 @@ import { and, eq } from "drizzle-orm";
import { agents, companies, createDb, executionWorkspaces, executionWorkspaceRepositories, issues } from "@paperclipai/db";
import { executionWorkspaceService } from "../services/execution-workspaces.js";
import { executionWorkspaceRepositoryService } from "../services/execution-workspace-repositories.js";
import { listLocalProjectRepositories } from "@paperclipai/adapter-utils/ssh";
import { readManagedWorkspaceRepositories, disposeGitWorkspaceSnapshot } from "@paperclipai/adapter-utils/git-workspace-sync";
import { captureDirectorySnapshot, disposeDirectorySnapshot } from "@paperclipai/adapter-utils/workspace-restore-merge";
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
const catalog = vi.hoisted(() => ({ available: true }));
@@ -93,6 +96,16 @@ const support = await getEmbeddedPostgresTestSupport();
new Promise<never>((_, reject) => { timeout = setTimeout(() => reject(new Error("Unrelated repository reuse waited for a blocked clone")), 2000); }),
]);
expect(ready[0]).toMatchObject({ id: readyIntent.operationId, pinnedCommit: pin });
// These are the actual SSH and native/sandbox discovery paths while
// the unrelated clone is incomplete, not inventory-only assertions.
expect(await listLocalProjectRepositories(taskRoot)).toEqual([readyIntent.repository.relativePath]);
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);
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); }
} finally {
clearTimeout(timeout);
await writeFile(cloneReleased, "released");
@@ -112,6 +125,23 @@ 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 () => {
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"));
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();
await executionWorkspaceRepositoryService(db).request({ companyId, issueId: task.id, actor,
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");
expect(await readdir(outside)).toEqual([]);
} finally { await rm(root, { recursive: true, force: true }); }
});
it("converges concurrent requests and persists each retry key without creating a project", async () => {
const [peerIssue] = await db.insert(issues).values({ companyId, title: "Peer task sharing the root", executionWorkspaceId: workspaceId }).returning();
await db.update(executionWorkspaces).set({ sourceIssueId: issueId }).where(eq(executionWorkspaces.id, workspaceId));
@@ -129,6 +129,19 @@ export function executionWorkspaceRepositoryService(db: Db) {
const repositoryRoot = path.join(root, ".paperclip-repositories");
await fs.mkdir(repositoryRoot, { recursive: true });
if ((await fs.lstat(repositoryRoot)).isSymbolicLink() || await fs.realpath(repositoryRoot) !== repositoryRoot) throw conflict("Repository directory escapes the task workspace");
// Incomplete clones belong to the runtime tree, which is excluded from
// transfer and recovery. The published namespace contains checkouts only.
let stagingParent = root;
for (const name of [".paperclip-runtime", "repository-staging"]) {
stagingParent = path.join(stagingParent, name);
await fs.mkdir(stagingParent, { mode: 0o700 }).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "EEXIST") throw error;
});
const entry = await fs.lstat(stagingParent);
if (!entry.isDirectory() || entry.isSymbolicLink() || await fs.realpath(stagingParent) !== stagingParent) {
throw conflict("Repository staging directory escapes the task workspace");
}
}
await ensureManagedRepositoriesIgnored(root);
const prepared: Array<{ id: string; cwd: string; repoUrl: string; relativePath: string; pinnedCommit: string; branchName: string | null }> = [];
for (const row of rows) {
@@ -167,6 +180,7 @@ export function executionWorkspaceRepositoryService(db: Db) {
}
const result = await materializeManagedProjectWorkspace(cwd, {
repoUrl: row.repoUrl,
stagingParent,
repoRef: authorizedPin ?? (row.requestedRef === "HEAD" ? null : row.requestedRef),
resolveGitAuth: source.catalogRepositoryId ? resolveAuth : async () => anonymous,
beforePublish: async cloneCwd => {
@@ -22,6 +22,8 @@ export async function materializeManagedProjectWorkspace(
resolveGitAuth?: GitRemoteAuthProvider | null;
/** Internal preparation receipt is committed inside the clone before atomic publication. */
beforePublish?: (cloneCwd: string) => Promise<void>;
/** Existing, validated private staging parent outside published repositories. */
stagingParent?: string;
},
): Promise<{ cwd: string; warning: string | null }> {
await fs.mkdir(path.dirname(cwd), { recursive: true });
@@ -54,14 +56,16 @@ export async function materializeManagedProjectWorkspace(
await fs.rm(cwd, { recursive: true, force: true });
}
// Clone into a temp sibling, then move into place atomically. The shared target directory
// Clone into a private temp directory (a sibling by default), then publish atomically. The target
// is never created in a partial state and never removed on failure, so a concurrent
// materialization (another process, or a run racing this one) can neither adopt a broken
// checkout nor lose its own completed one.
const auth = input.resolveGitAuth && !input.localSource
? await input.resolveGitAuth(input.repoUrl)
: null;
const cloneTmpDir = await fs.mkdtemp(`${cwd}.clone-`);
const cloneTmpDir = await fs.mkdtemp(input.stagingParent
? path.join(input.stagingParent, `${path.basename(cwd)}.clone-`)
: `${cwd}.clone-`);
try {
try {
await execFile(