Files
PaperClipAI/packages/adapter-utils/src/remote-managed-runtime.test.ts
T
Nicky LeachandPaperclip 6a267e0328 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>
2026-07-29 16:09:57 -07:00

187 lines
6.7 KiB
TypeScript

import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
const {
prepareWorkspaceForSshExecution,
restoreWorkspaceFromSshExecution,
runSshCommand,
syncDirectoryToSsh,
} = vi.hoisted(() => ({
prepareWorkspaceForSshExecution: vi.fn(async () => ({ gitBacked: false })),
restoreWorkspaceFromSshExecution: vi.fn(async () => undefined),
runSshCommand: vi.fn(async () => ({
stdout: Buffer.from('{"token":"remote"}\n').toString("base64"),
stderr: "",
})),
syncDirectoryToSsh: vi.fn(async (_input: { localDir: string }) => undefined),
}));
vi.mock("./ssh.js", () => ({
prepareWorkspaceForSshExecution,
restoreWorkspaceFromSshExecution,
runSshCommand,
syncDirectoryToSsh,
}));
import { prepareRemoteManagedRuntime } from "./remote-managed-runtime.js";
describe("remote managed runtime", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
vi.clearAllMocks();
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("restores runtime assets without restoring an in-place SSH workspace", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-assets-only-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const homeDir = path.join(rootDir, "home");
await mkdir(workspaceDir, { recursive: true });
await mkdir(homeDir, { recursive: true });
await writeFile(path.join(homeDir, "auth.json"), '{"token":"host"}\n', "utf8");
let restoredAuth = "";
const prepared = await prepareRemoteManagedRuntime({
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/app",
remoteCwd: "/app",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
strictHostKeyChecking: true,
},
runId: "run-in-place",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
assets: [
{
key: "home",
localDir: homeDir,
restore: async ({ assetDir, readFile }) => {
restoredAuth = (await readFile(path.posix.join(assetDir, "auth.json"))).toString("utf8");
},
},
],
});
expect(prepareWorkspaceForSshExecution).not.toHaveBeenCalled();
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
localDir: homeDir,
remoteDir: "/app/.paperclip-runtime/codex/home",
}));
await prepared.restoreWorkspace();
expect(restoreWorkspaceFromSshExecution).not.toHaveBeenCalled();
expect(runSshCommand).toHaveBeenCalledWith(
expect.anything(),
"base64 < '/app/.paperclip-runtime/codex/home/auth.json'",
{ maxBuffer: 1024 * 1024 },
);
expect(restoredAuth).toBe('{"token":"remote"}\n');
});
it("stages each additional project into its own isolated SSH dir, isolating one failure", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-additional-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const firstDir = path.join(rootDir, "referenced-first");
const secondDir = path.join(rootDir, "referenced-second");
const brokenDir = path.join(rootDir, "referenced-broken");
await mkdir(workspaceDir, { recursive: true });
// The transfer rejects only for the broken project's directory.
syncDirectoryToSsh.mockImplementation(async (input: { localDir: string }) => {
if (input.localDir === brokenDir) throw new Error("ssh transfer failed");
return undefined;
});
const prepared = await prepareRemoteManagedRuntime({
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/app",
remoteCwd: "/app",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
strictHostKeyChecking: true,
},
runId: "run-additional",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [
{ localPath: firstDir, projectId: "first" },
{ localPath: brokenDir, projectId: "broken" },
{ localPath: secondDir, projectId: "second" },
],
});
// Each healthy project staged into its OWN isolated dir under the runtime
// root; the broken one is skipped, not fatal.
expect(Object.keys(prepared.additionalSourceDirs).sort()).toEqual(["first", "second"]);
expect(prepared.additionalSourceDirs.first).toBe("/app/.paperclip-runtime/codex/project-first");
expect(prepared.additionalSourceDirs.second).toBe("/app/.paperclip-runtime/codex/project-second");
expect(prepared.additionalSourceDirs.broken).toBeUndefined();
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
localDir: firstDir,
remoteDir: "/app/.paperclip-runtime/codex/project-first",
}));
expect(syncDirectoryToSsh).toHaveBeenCalledWith(expect.objectContaining({
localDir: secondDir,
remoteDir: "/app/.paperclip-runtime/codex/project-second",
}));
});
it("skips an additional project whose localPath is not absolute", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-remote-runtime-relative-"));
cleanupDirs.push(rootDir);
const workspaceDir = path.join(rootDir, "workspace");
const healthyDir = path.join(rootDir, "referenced-healthy");
await mkdir(workspaceDir, { recursive: true });
const prepared = await prepareRemoteManagedRuntime({
spec: {
host: "127.0.0.1",
port: 2222,
username: "fixture",
remoteWorkspacePath: "/app",
remoteCwd: "/app",
privateKey: "PRIVATE KEY",
knownHosts: "KNOWN HOSTS",
strictHostKeyChecking: true,
},
runId: "run-relative",
adapterKey: "codex",
workspaceLocalDir: workspaceDir,
workspaceRemoteDir: "/app",
syncWorkspace: false,
additionalSources: [
{ localPath: "relative/referenced", projectId: "relative" },
{ localPath: healthyDir, projectId: "healthy" },
],
});
// The relative-path project never reaches the transfer and is skipped; the
// absolute-path project still stages.
expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["healthy"]);
expect(syncDirectoryToSsh).not.toHaveBeenCalledWith(expect.objectContaining({
localDir: "relative/referenced",
}));
});
});