Fix relative symlinks in secondary sandbox repositories (#13953)

## Thinking Path

> - Paperclip manages agents and their task workspaces.
> - A task can use several independent Git repositories.
> - Sandbox staging copies secondary repositories from temporary clones.
> - The copy changed relative symlinks into absolute host paths.
> - Those links broke skill discovery and made workspace restore fail.
> - This change preserves link targets while keeping extraction checks
intact.

## Linked Issues or Issue Description

**What happened?**

Staging a secondary repository rewrites a link such as
`.claude/skills/demo -> ../../skills/demo` to an absolute path in a
temporary Git clone. That clone is then removed. The link is broken in
the sandbox, and Daytona refuses the outbound archive during workspace
restore. An agent can finish its turn but still have its run fail during
restore.

**Expected behavior**

Repository links keep their original targets after staging. Links within
a repository remain usable, and changes return to the local checkout.
Unsafe outbound archive links still fail before extraction.

**Steps to reproduce**

1. Create a project with a primary repository and a secondary
repository.
2. Commit a relative skill directory link in the secondary repository.
3. Stage the workspace for sandbox execution and inspect the copied
link.
4. Restore that repository through Daytona. Before this fix, the link
points at a removed host temporary directory and restore rejects it.

**Paperclip version/commit**

Reproduced on `0f8750627f11d855552abce9a837d7f3b67c9ddf` in the
multi-repository sandbox path.

Related: #13442 introduced multi-repository provisioning. #13882 adds
native Grok but keeps legacy adapters; #12991 addresses Grok instruction
isolation and leaves skill staging unchanged. Searches of open/closed
PRs and open issues found no direct fix for this copy behavior.

## What Changed

- Set `verbatimSymlinks: true` when copying secondary Git clones. This
[Node
option](https://nodejs.org/api/fs.html#fspromisescpsrc-dest-options)
preserves the stored link target instead of resolving it against the
temporary source.
- Test directory, file, chained and dangling links after temporary-clone
cleanup. Assert the copied Git checkout remains clean.
- Cover skill-link reads, edits and restore in fresh, warm-adoption and
durable-seed workspace modes.
- Extend Daytona checks for valid relative directory links and rejected
absolute targets.
- Document the staging behavior.

## Verification

- Four regression cases fail without the source fix: one clone test and
three staging modes.
- `pnpm exec vitest run
packages/adapter-utils/src/git-workspace-sync.test.ts
packages/adapter-utils/src/sandbox-managed-runtime.test.ts
packages/plugins/sandbox-providers/daytona/src/plugin.test.ts`: 301
passed.
- `pnpm -r typecheck` and `pnpm build`: passed.
- `pnpm test:run` was started locally, then stopped after the full Linux
CI suite passed. It did not finish locally; this is not a full
local-suite pass.
- Full PR CI: 53 checks passed, two conditional skips, on
`07b30ff298baab327a4e60708ade899935688a3f`. Three server shards were
interrupted by runner shutdowns; the unchanged mobile repository test
timed out waiting for a disabled Save changes button. One same-commit
failed-job rerun passed. Original attempts remain in [run
36036538369](https://github.com/paperclipai/paperclip/actions/runs/36036538369).
- Greptile: 5/5 on the same head, no review threads or actionable
findings.
- Diff scanned for secrets and private identifiers; no matches.

## Risks

Low risk: the production change is one copy option. It preserves
symlinks instead of following or materializing their targets. Daytona
extraction guards, workspace exclusions, authentication and database
behavior do not change.

This prevents corruption in newly staged snapshots. It does not rewrite
an already corrupted warm workspace or durable seed; those need fresh
staging from the source checkout. No live provider run or customer-task
replay was performed. The tests use real Git, filesystem and tar
operations with mocked provider transport.

## Model Used

OpenAI GPT-6 through Codex, with tool use and code execution. The exact
serving model identifier and context-window size are not exposed in this
session.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Devin FoleyandPaperclip authored and GitHub committed 2026-09-24 10:58:49 -07:00
1 parent 0f8750627f
commit f3a214fe77
5 files changed
+64 -6

No files matched your search

+2
View File
@@ -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
@@ -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);
@@ -593,7 +593,13 @@ export async function withShallowGitWorkspaceClone<T>(
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) {
@@ -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);
@@ -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");
});
// -------------------------------------------------------------------------