mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 09:37:19 +01:00
fix: stage repository clones outside published workspace files
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user