feat(sandbox): stage referenced projects into the run sandbox (#10469)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Runs can reference more than one project
> - Each referenced project must land in its own sandbox tree so one run
does not mix files across projects
> - The anchor workspace must keep its own git history and overlay rules
> - Fail closed on sync and confinement errors, and keep the other
projects alive
> - This pull request stages referenced projects into isolated project
directories under the run sandbox root
> - The benefit is safer multi-project runs with clear failure isolation

## Linked Issues or Issue Description

No public issue exists.

Related PR: #10448.

## What Changed

- Thread additional referenced-project sources through the run prepare
path and the runtime layers.
- Stage each referenced project into its own `project-<projectId>`
directory under the runtime root.
- Keep the anchor workspace history and overlay semantics unchanged.
- Fail closed on confinement or sync errors for one project, and keep
the other projects running.
- Keep the path inert by default behind the multi-project workspace-sync
kill-switch.
- No documentation update was needed for this code-only runtime change.

## Verification

- `pnpm --filter @paperclipai/adapter-utils exec vitest run
src/sandbox-file-sync.test.ts src/command-managed-runtime.test.ts
src/sandbox-managed-runtime.test.ts src/remote-managed-runtime.test.ts`
- `pnpm --filter @paperclipai/adapter-utils exec tsc --noEmit`
- The branch points at `871378e4064a95adc8e4647e442ec1359768de61` on
`origin/feat/stage-referenced-projects-into-sandbox`.
- GitHub CI is green on PR #10469.

## Risks

- A referenced project can skip if confinement or sync fails.
- A new runtime tree layout can affect tools that assume one project
root.
- The kill-switch keeps the path inert until operators enable it.

## Model Used

OpenAI Codex, GPT-5, tool use enabled.

## 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 or
confirmed no docs update was needed
- [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:
Nicky LeachandPaperclip authored and GitHub committed 2026-07-29 16:09:57 -07:00
1 parent 5c5366d0c1
commit 6a267e0328
10 files changed
+845 -36

No files matched your search

@@ -303,6 +303,59 @@ describe("command managed runtime", () => {
);
});
it("stages each additional project into an isolated dir on the base64/tar transport, one failure skipped", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-additional-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(remoteWorkspaceDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8");
const goodOne = path.join(rootDir, "src-one");
const goodTwo = path.join(rootDir, "src-two");
await mkdir(goodOne, { recursive: true });
await mkdir(path.join(goodTwo, "nested"), { recursive: true });
await writeFile(path.join(goodOne, "one.txt"), "one body\n", "utf8");
await writeFile(path.join(goodTwo, "nested", "two.txt"), "two body\n", "utf8");
// The `makeSpawnRunner` runner exposes no native syncIn, so staging rides the
// base64/tar fallback. The middle source points at a missing directory, so
// its tar build fails; failure isolation skips only it.
const { runner } = makeSpawnRunner();
const prepared = await prepareCommandManagedRuntime({
runner,
spec: {
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
},
adapterKey: "claude",
workspaceLocalDir: localWorkspaceDir,
additionalSources: [
{ localPath: goodOne, projectId: "one" },
{ localPath: path.join(rootDir, "missing"), projectId: "broken" },
{ localPath: goodTwo, projectId: "two" },
],
});
const runtimeRootDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "claude");
expect(Object.keys(prepared.additionalSourceDirs).sort()).toEqual(["one", "two"]);
expect(prepared.additionalSourceDirs.one).toBe(path.posix.join(runtimeRootDir, "project-one"));
expect(prepared.additionalSourceDirs.two).toBe(path.posix.join(runtimeRootDir, "project-two"));
expect(prepared.additionalSourceDirs.broken).toBeUndefined();
// Each healthy project's tree materialized in its OWN dir (nested files kept).
await expect(readFile(path.join(prepared.additionalSourceDirs.one, "one.txt"), "utf8")).resolves.toBe("one body\n");
await expect(readFile(path.join(prepared.additionalSourceDirs.two, "nested", "two.txt"), "utf8")).resolves.toBe(
"two body\n",
);
// The broken project's dir was never created.
await expect(readFile(path.join(runtimeRootDir, "project-broken"), "utf8")).rejects.toMatchObject({
code: "ENOENT",
});
});
it("keeps adapter detection on the profile-backed shell path", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-command-runtime-detect-"));
cleanupDirs.push(rootDir);