diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index bf5ab10e4df..6b2596f35b9 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -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"); }); diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index 4ee9df6876a..710c237b2cb 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -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, diff --git a/packages/adapter-utils/src/ssh.ts b/packages/adapter-utils/src/ssh.ts index 68e61fc6390..ddb07d6ac7b 100644 --- a/packages/adapter-utils/src/ssh.ts +++ b/packages/adapter-utils/src/ssh.ts @@ -1090,7 +1090,7 @@ async function isLocalGitRepositoryRoot(localDir: string): Promise { } } -async function listLocalProjectRepositories(localDir: string): Promise { +export async function listLocalProjectRepositories(localDir: string): Promise { const root = path.join(localDir, PROJECT_REPOSITORIES_DIR); const rootStat = await fs.lstat(root).catch((error: NodeJS.ErrnoException) => { if (error.code === "ENOENT") return null; diff --git a/server/src/__tests__/task-workspace-repositories-persistence.test.ts b/server/src/__tests__/task-workspace-repositories-persistence.test.ts index a84a1f485af..1ca72672573 100644 --- a/server/src/__tests__/task-workspace-repositories-persistence.test.ts +++ b/server/src/__tests__/task-workspace-repositories-persistence.test.ts @@ -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((_, 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)); diff --git a/server/src/services/execution-workspace-repositories.ts b/server/src/services/execution-workspace-repositories.ts index 73d1acc58c0..87416e73b6e 100644 --- a/server/src/services/execution-workspace-repositories.ts +++ b/server/src/services/execution-workspace-repositories.ts @@ -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 => { diff --git a/server/src/services/managed-repository-checkout.ts b/server/src/services/managed-repository-checkout.ts index 49919bfde0d..e758479b348 100644 --- a/server/src/services/managed-repository-checkout.ts +++ b/server/src/services/managed-repository-checkout.ts @@ -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; + /** 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(