diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index d3e04011e1..a099d711f3 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -690,6 +690,8 @@ When an additional repository has a configured local checkout, Paperclip seeds t Sandbox staging, including Daytona, transfers each repository's Git history and working files. Restore merges files and commits back into each local task checkout independently. Durable sandbox recovery keeps the same repository snapshots. Normal ignore and workspace exclusion rules still apply. A clone failure stops task preparation with an error so the agent does not start with only part of the project. +Staging preserves relative symlink targets in secondary repositories, including skill links such as `.claude/skills/demo -> ../../skills/demo`. It does not rewrite them to host temporary paths or copy their target contents in place of the link. Daytona still rejects outbound archives with absolute or escaping link targets before extraction. + If a repository is detached or its source configuration changes, its previous task copy is retained under `.paperclip-runtime/detached-repositories/` and excluded from future sandbox transfers. Referenced projects continue to use the separate read-only multi-project workspace behavior. ## Config Freshness diff --git a/packages/adapter-utils/src/git-workspace-sync.test.ts b/packages/adapter-utils/src/git-workspace-sync.test.ts index 163df791b3..5a5e054d5a 100644 --- a/packages/adapter-utils/src/git-workspace-sync.test.ts +++ b/packages/adapter-utils/src/git-workspace-sync.test.ts @@ -1,5 +1,5 @@ import { execFile as execFileCallback } from "node:child_process"; -import { lstat, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { promisify } from "node:util"; @@ -186,6 +186,43 @@ describe("git workspace sync", () => { }); }); + it.skipIf(process.platform === "win32")("preserves nested repository symlinks after the temporary clone is removed", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-nested-links-")); + cleanupDirs.push(rootDir); + const repo = await createRepo(rootDir); + const nested = await createRepo(path.join(repo, ".paperclip-repositories")); + await writeFile(path.join(repo, ".git/info/exclude"), ".paperclip-repositories/\n"); + await mkdir(path.join(nested, "skills", "demo"), { recursive: true }); + await mkdir(path.join(nested, ".claude", "skills"), { recursive: true }); + await writeFile(path.join(nested, "skills", "demo", "SKILL.md"), "skill content\n"); + const links = [ + [".claude/skills/demo", "../../skills/demo"], + ["skill.md", "skills/demo/SKILL.md"], + ["skill-alias", ".claude/skills/demo"], + ["future", "future.txt"], + ] as const; + for (const [name, target] of links) await symlink(target, path.join(nested, name)); + await git(nested, ["add", "."]); + await git(nested, ["commit", "-m", "add repository links"]); + const snapshot = await readGitWorkspaceSnapshot(repo); + expect(snapshot?.repositories).toHaveLength(1); + + await withShallowGitWorkspaceClone({ localDir: repo, snapshot: snapshot! }, async (cloneDir) => { + // The nested clone's callback has already returned and deleted its temp + // directory. Relative links must keep their repository meaning here. + const copied = path.join(cloneDir, ".paperclip-repositories", "repo"); + for (const [name, target] of links) { + expect((await lstat(path.join(copied, name))).isSymbolicLink()).toBe(true); + expect(await readlink(path.join(copied, name))).toBe(target); + } + expect(await readFile(path.join(copied, ".claude/skills/demo/SKILL.md"), "utf8")).toBe("skill content\n"); + expect(await readFile(path.join(copied, "skill-alias/SKILL.md"), "utf8")).toBe("skill content\n"); + await expect(stat(path.join(copied, "future"))).rejects.toMatchObject({ code: "ENOENT" }); + expect(await git(copied, ["status", "--porcelain"])).toBe(""); + }); + expect(await git(nested, ["status", "--porcelain"])).toBe(""); + }); + it("copies the workspace origin remote into the shallow clone", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-git-origin-")); cleanupDirs.push(rootDir); diff --git a/packages/adapter-utils/src/git-workspace-sync.ts b/packages/adapter-utils/src/git-workspace-sync.ts index 42bc21250e..f3ade95f7c 100644 --- a/packages/adapter-utils/src/git-workspace-sync.ts +++ b/packages/adapter-utils/src/git-workspace-sync.ts @@ -593,7 +593,13 @@ export async function withShallowGitWorkspaceClone( localDir: path.join(input.localDir, repository.path), snapshot: repository.snapshot, }, async (nestedClone) => { - await fs.cp(nestedClone, path.join(cloneDir, repository.path), { recursive: true }); + // Preserve repository-relative links. fs.cp otherwise rewrites them to + // absolute paths into nestedClone, which is deleted after this callback + // and is outside the workspace when the sandbox restores its files. + await fs.cp(nestedClone, path.join(cloneDir, repository.path), { + recursive: true, + verbatimSymlinks: true, + }); }); } if (input.snapshot.repositories?.length) { diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index d2cb043340..05f8cec2ea 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -1,6 +1,6 @@ import { randomBytes } from "node:crypto"; import { promises as fsPromises } from "node:fs"; -import { lstat, mkdir, mkdtemp, readFile, readdir, rm, stat, symlink, writeFile } from "node:fs/promises"; +import { lstat, mkdir, mkdtemp, readFile, readdir, readlink, rm, stat, symlink, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { execFile as execFileCallback, spawn } from "node:child_process"; @@ -354,6 +354,10 @@ describe("sandbox managed runtime", () => { await git(cwd, ["config", "user.email", "test@example.com"]); await writeFile(path.join(cwd, "README.md"), contents!); await writeFile(path.join(cwd, ".gitignore"), "secret.txt\n"); + await mkdir(path.join(cwd, ".claude/skills"), { recursive: true }); + await mkdir(path.join(cwd, "skills/demo"), { recursive: true }); + await writeFile(path.join(cwd, "skills/demo/SKILL.md"), "base skill\n"); + await symlink("../../skills/demo", path.join(cwd, ".claude/skills/demo")); await git(cwd, ["add", "."]); await git(cwd, ["commit", "-m", contents!]); await writeFile(path.join(cwd, "secret.txt"), "must stay local"); @@ -381,8 +385,11 @@ describe("sandbox managed runtime", () => { for (const relative of ["", secondPath]) { const cwd = path.join(remote, relative); expect((await lstat(path.join(cwd, ".git"))).isDirectory()).toBe(true); + expect(await readlink(path.join(cwd, ".claude/skills/demo"))).toBe("../../skills/demo"); + expect(await readFile(path.join(cwd, ".claude/skills/demo/SKILL.md"), "utf8")).toBe("base skill\n"); await expect(stat(path.join(cwd, "secret.txt"))).rejects.toMatchObject({ code: "ENOENT" }); await writeFile(path.join(cwd, "README.md"), `updated ${relative}`); + await writeFile(path.join(cwd, ".claude/skills/demo/SKILL.md"), "updated skill\n"); await git(cwd, ["-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-am", "remote change"]); } expect(await readFile(path.join(remote, secondPath, "dirty.txt"), "utf8")).toBe("local edit"); @@ -395,6 +402,8 @@ describe("sandbox managed runtime", () => { expect(await readFile(path.join(local, relative, "README.md"), "utf8")).toBe(`updated ${relative}`); expect(await git(path.join(local, relative), ["log", "-1", "--format=%s"])).toBe("remote change"); expect(await readFile(path.join(local, relative, "secret.txt"), "utf8")).toBe("must stay local"); + expect(await readlink(path.join(local, relative, ".claude/skills/demo"))).toBe("../../skills/demo"); + expect(await readFile(path.join(local, relative, ".claude/skills/demo/SKILL.md"), "utf8")).toBe("updated skill\n"); } }, 30_000); diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 066436c998..d15bbe9814 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -4652,18 +4652,18 @@ describe("daytona native file-sync hooks", () => { await expect(fs.stat(path.join(hostRoot, "escape.txt"))).rejects.toThrow(); }); - it("syncOut refuses a sandbox-authored tarball carrying a symlink whose target escapes the extraction dir", async () => { + it.each(["../../outside.txt", "/tmp/paperclip-git-workspace-example/skills/demo"])("syncOut refuses a sandbox-authored tarball with escaping symlink target %s", async (linkTarget) => { const hostRoot = await makeHostDir(); const restored = path.join(hostRoot, "restored"); const sandbox = createMockSandbox(); sandbox.fs.downloadFiles.mockImplementation(async (requests: Array<{ source: string; destination?: string }>) => { return Promise.all( requests.map(async (req) => { - // Craft a tar whose sole member is a symlink pointing above the tree. + // Craft a tar whose sole member is a symlink pointing outside the tree. const staging = await fs.mkdtemp(path.join(os.tmpdir(), "daytona-evil-")); tempDirs.push(staging); await fs.mkdir(path.join(staging, "sub"), { recursive: true }); - await fs.symlink("../../outside.txt", path.join(staging, "sub", "evil")); + await fs.symlink(linkTarget, path.join(staging, "sub", "evil")); execFileSync("tar", ["-cf", req.destination!, "-C", path.join(staging, "sub"), "evil"]); return { source: req.source, result: req.destination }; }), @@ -4741,6 +4741,8 @@ describe("daytona native file-sync hooks", () => { await fs.writeFile(path.join(source, "secret"), "top-secret"); await fs.chmod(path.join(source, "secret"), 0o600); await fs.symlink("nested/data.txt", path.join(source, "shortcut")); + await fs.mkdir(path.join(source, ".claude", "skills"), { recursive: true }); + await fs.symlink("../../nested", path.join(source, ".claude", "skills", "demo")); // Simulate the sandbox filesystem with a host-side directory the mock tar // commands operate on, so the round-trip exercises real tar create/extract. @@ -4798,6 +4800,8 @@ describe("daytona native file-sync hooks", () => { const linkStat = await fs.lstat(path.join(restored, "shortcut")); expect(linkStat.isSymbolicLink()).toBe(true); expect(await fs.readlink(path.join(restored, "shortcut"))).toBe("nested/data.txt"); + expect(await fs.readlink(path.join(restored, ".claude", "skills", "demo"))).toBe("../../nested"); + expect(await fs.readFile(path.join(restored, ".claude", "skills", "demo", "data.txt"), "utf8")).toBe("hello world"); }); // -------------------------------------------------------------------------