Files
PaperClipAI/packages/adapter-utils/src/sandbox-managed-runtime.test.ts
T
Devin FoleyandPaperclip 721541c41d Fix sandbox restore index drift (#8595)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Sandboxed agents can do useful work in a remote workspace, then
export that result back to a host review checkout.
> - A restore can leave the host checkout's index stale even when the
sandbox content and committed state are correct.
> - That drift makes reviewers see misleading missing-file or
dirty-state results after sandbox execution.
> - This pull request repairs the host index refresh path and surfaces a
clean/dirty restore signal during finalization.
> - The benefit is more reliable review checkouts and clearer sandbox
finalization status.

## Linked Issues or Issue Description

Refs #248

No exact public GitHub issue was found for this restore/index
consistency fix. The underlying bug is that a sandbox restore can update
host working-tree content without leaving the host git index aligned, so
local review tooling may report stale deletions or misleading dirtiness.
This PR keeps the restore/export path consistent and reports the
resulting clean/dirty state.

GitHub search performed for related or duplicate work: `sandbox runtime
status`, `sandbox restore index`, and `runtime progress`. No direct
duplicate PR was found.

## What Changed

- Refreshed git workspace sync/index handling after sandbox
restore/export operations.
- Added finalization status that reports whether the restored host
checkout is clean or dirty.
- Preserved sandbox-managed runtime progress callbacks around restore
and finalization.
- Added adapter-utils tests covering the host index drift repair and
clean/dirty finalization signal.

## Verification

- Local PII scan before push: high-confidence secret patterns, internal
issue links, local user paths, and private URL patterns checked across
all three split diffs; no real secrets or internal links found.
- `git diff --check
feat/sandbox-runtime-status..fix/sandbox-restore-index-sync`
- `pnpm exec vitest run
packages/adapter-utils/src/sandbox-managed-runtime.test.ts` — 1 file, 10
tests passed.
- `pnpm run typecheck` passed on `fix/sandbox-restore-index-sync`.
- `pnpm run build` passed on `fix/sandbox-restore-index-sync`; Vite
reported existing CSS `::highlight` and chunk-size warnings.
- `pnpm run test:run` was attempted on `fix/sandbox-restore-index-sync`;
it failed in two unrelated broad-suite tests. One depends on this host's
Git default branch behavior, and one depends on local Claude
model-discovery environment. The changed focused suite above passes.

## Risks

- Git index refresh behavior needs to remain conservative so it does not
hide real uncommitted user changes.
- The clean/dirty finalization signal is diagnostic; it should not by
itself mark a sandbox run successful or failed.
- This PR is stacked on the runtime-status branch so finalization
progress can reuse the same callback path.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI GPT-5 via Codex coding agent, with shell/tool execution in a
local worktree. Exact context-window metadata is not exposed by the
runtime.

## 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>
2026-06-24 23:21:13 -07:00

780 lines
38 KiB
TypeScript

import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { execFile as execFileCallback } from "node:child_process";
import { promisify } from "node:util";
import { afterEach, describe, expect, it, vi } from "vitest";
import { resetLocalGitIndexToHead } from "./git-workspace-sync.js";
import {
mirrorDirectory,
prepareSandboxManagedRuntime,
type SandboxManagedRuntimeClient,
} from "./sandbox-managed-runtime.js";
const execFile = promisify(execFileCallback);
async function git(cwd: string, args: string[]): Promise<string> {
const { stdout } = await execFile("git", ["-C", cwd, ...args], {
maxBuffer: 32 * 1024 * 1024,
});
return stdout.trim();
}
async function listTarMembers(rootDir: string, name: string, bytes: Buffer): Promise<string[]> {
const tarPath = path.join(rootDir, name);
await writeFile(tarPath, bytes);
const { stdout } = await execFile("tar", ["-tf", tarPath], { maxBuffer: 32 * 1024 * 1024 });
return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
}
describe("sandbox managed runtime", () => {
const cleanupDirs: string[] = [];
afterEach(async () => {
while (cleanupDirs.length > 0) {
const dir = cleanupDirs.pop();
if (!dir) continue;
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
}
});
it("preserves excluded local workspace artifacts during restore mirroring", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-restore-"));
cleanupDirs.push(rootDir);
const sourceDir = path.join(rootDir, "source");
const targetDir = path.join(rootDir, "target");
await mkdir(path.join(sourceDir, "src"), { recursive: true });
await mkdir(path.join(targetDir, ".claude"), { recursive: true });
await mkdir(path.join(targetDir, ".paperclip-runtime"), { recursive: true });
await writeFile(path.join(sourceDir, "src", "app.ts"), "export const value = 2;\n", "utf8");
await writeFile(path.join(targetDir, "stale.txt"), "remove me\n", "utf8");
await writeFile(path.join(targetDir, ".claude", "settings.json"), "{\"keep\":true}\n", "utf8");
await writeFile(path.join(targetDir, ".claude.json"), "{\"keep\":true}\n", "utf8");
await writeFile(path.join(targetDir, ".paperclip-runtime", "state.json"), "{}\n", "utf8");
await mirrorDirectory(sourceDir, targetDir, {
preserveAbsent: [".paperclip-runtime", ".claude", ".claude.json"],
});
await expect(readFile(path.join(targetDir, "src", "app.ts"), "utf8")).resolves.toBe("export const value = 2;\n");
await expect(readFile(path.join(targetDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"keep\":true}\n");
await expect(readFile(path.join(targetDir, ".claude.json"), "utf8")).resolves.toBe("{\"keep\":true}\n");
await expect(readFile(path.join(targetDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n");
await expect(readFile(path.join(targetDir, "stale.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
});
it("syncs workspace and assets through a provider-neutral sandbox client", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-managed-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const localAssetsDir = path.join(rootDir, "local-assets");
const linkedAssetPath = path.join(rootDir, "linked-skill.md");
await mkdir(path.join(localWorkspaceDir, ".claude"), { recursive: true });
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");
await writeFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "{\"local\":true}\n", "utf8");
await writeFile(linkedAssetPath, "skill body\n", "utf8");
await symlink(linkedAssetPath, path.join(localAssetsDir, "skill.md"));
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes) => {
await mkdir(path.dirname(remotePath), { recursive: true });
await writeFile(remotePath, Buffer.from(bytes));
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async (remotePath) => {
const entries = await readdir(remotePath, { withFileTypes: true }).catch(() => []);
return entries
.filter((entry) => entry.isFile())
.map((entry) => entry.name)
.sort((left, right) => left.localeCompare(right));
},
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
await execFile("sh", ["-c", command], {
maxBuffer: 32 * 1024 * 1024,
});
},
};
const runtimeStatuses: string[] = [];
const prepared = await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
workspaceExclude: [".claude"],
preserveAbsentOnRestore: [".claude"],
onRuntimeProgress: async (status) => {
runtimeStatuses.push(`${status.phase}:${status.message}`);
},
assets: [{
key: "skills",
localDir: localAssetsDir,
followSymlinks: true,
}],
});
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("local workspace\n");
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");
expect((await lstat(path.join(prepared.assetDirs.skills, "skill.md"))).isFile()).toBe(true);
await writeFile(path.join(remoteWorkspaceDir, "README.md"), "remote workspace\n", "utf8");
await writeFile(path.join(remoteWorkspaceDir, "remote-only.txt"), "sync back\n", "utf8");
await mkdir(path.join(localWorkspaceDir, ".paperclip-runtime"), { recursive: true });
await writeFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "{}\n", "utf8");
await writeFile(path.join(localWorkspaceDir, "local-stale.txt"), "remove\n", "utf8");
await prepared.restoreWorkspace();
await expect(readFile(path.join(localWorkspaceDir, "README.md"), "utf8")).resolves.toBe("remote workspace\n");
await expect(readFile(path.join(localWorkspaceDir, "remote-only.txt"), "utf8")).resolves.toBe("sync back\n");
await expect(readFile(path.join(localWorkspaceDir, "local-stale.txt"), "utf8")).resolves.toBe("remove\n");
await expect(readFile(path.join(localWorkspaceDir, ".claude", "settings.json"), "utf8")).resolves.toBe("{\"local\":true}\n");
await expect(readFile(path.join(localWorkspaceDir, ".paperclip-runtime", "state.json"), "utf8")).resolves.toBe("{}\n");
expect(runtimeStatuses).toEqual([
"config_sync:Syncing workspace to sandbox",
"config_sync:Syncing runtime assets to sandbox",
"restore:Restoring workspace from sandbox",
"finalize:Finalizing sandbox workspace",
]);
});
it("syncs git-backed workspaces through a shallow standalone clone and keeps .git out of archives", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-git-"));
cleanupDirs.push(rootDir);
const sourceRepoDir = path.join(rootDir, "source-repo");
const localWorkspaceDir = path.join(rootDir, "local-worktree");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(sourceRepoDir, { recursive: true });
await git(sourceRepoDir, ["init"]);
await git(sourceRepoDir, ["checkout", "-b", "main"]);
await git(sourceRepoDir, ["config", "user.name", "Paperclip Test"]);
await git(sourceRepoDir, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(sourceRepoDir, ".gitignore"), "node_modules/\n", "utf8");
await writeFile(path.join(sourceRepoDir, "tracked.txt"), "base\n", "utf8");
await writeFile(path.join(sourceRepoDir, "clean.txt"), "from git\n", "utf8");
await writeFile(path.join(sourceRepoDir, "deleted.txt"), "delete me\n", "utf8");
await git(sourceRepoDir, ["add", ".gitignore", "tracked.txt", "clean.txt", "deleted.txt"]);
await git(sourceRepoDir, ["commit", "-m", "base"]);
await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]);
expect((await lstat(path.join(localWorkspaceDir, ".git"))).isFile()).toBe(true);
await mkdir(path.join(localWorkspaceDir, "node_modules"), { recursive: true });
await writeFile(path.join(localWorkspaceDir, "tracked.txt"), "dirty local\n", "utf8");
await writeFile(path.join(localWorkspaceDir, "untracked.txt"), "from local\n", "utf8");
await writeFile(path.join(localWorkspaceDir, "node_modules", "cache.bin"), "do not upload\n", "utf8");
await rm(path.join(localWorkspaceDir, "deleted.txt"));
const uploadedTars: { remotePath: string; bytes: Buffer }[] = [];
const downloadedTars: { remotePath: string; bytes: Buffer }[] = [];
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes) => {
await mkdir(path.dirname(remotePath), { recursive: true });
const buffer = Buffer.from(bytes);
if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer });
await writeFile(remotePath, buffer);
},
readFile: async (remotePath) => {
const buffer = await readFile(remotePath);
if (remotePath.endsWith("workspace-download.tar")) downloadedTars.push({ remotePath, bytes: buffer });
return buffer;
},
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
},
};
const runtimeStatusPhases: string[] = [];
const prepared = await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
onRuntimeProgress: async (status) => {
runtimeStatusPhases.push(status.phase);
},
});
expect((await lstat(path.join(remoteWorkspaceDir, ".git"))).isDirectory()).toBe(true);
await expect(readFile(path.join(remoteWorkspaceDir, ".git", "shallow"), "utf8")).resolves.toContain(
await git(localWorkspaceDir, ["rev-parse", "HEAD"]),
);
expect(await git(remoteWorkspaceDir, ["rev-list", "--count", "HEAD"])).toBe("1");
expect(await git(remoteWorkspaceDir, ["status", "--short"])).toContain("M tracked.txt");
expect(await git(remoteWorkspaceDir, ["status", "--short"])).toContain("?? untracked.txt");
await expect(readFile(path.join(remoteWorkspaceDir, "deleted.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
const gitUpload = uploadedTars.find((entry) => path.posix.basename(entry.remotePath) === "git-workspace-upload.tar");
const workspaceUpload = uploadedTars.find((entry) => path.posix.basename(entry.remotePath) === "workspace-upload.tar");
expect(gitUpload).toBeDefined();
expect(workspaceUpload).toBeDefined();
const gitMembers = await listTarMembers(rootDir, "git-upload-list.tar", gitUpload!.bytes);
const workspaceMembers = await listTarMembers(rootDir, "workspace-upload-list.tar", workspaceUpload!.bytes);
expect(gitMembers.some((entry) => entry === ".git" || entry.startsWith(".git/"))).toBe(true);
expect(workspaceMembers.some((entry) => entry === ".git" || entry.startsWith(".git/"))).toBe(false);
expect(workspaceMembers).toContain("tracked.txt");
expect(workspaceMembers).toContain("untracked.txt");
expect(workspaceMembers).not.toContain("clean.txt");
expect(workspaceMembers.some((entry) => entry === "node_modules" || entry.startsWith("node_modules/"))).toBe(false);
await git(remoteWorkspaceDir, ["config", "user.name", "Paperclip Sandbox"]);
await git(remoteWorkspaceDir, ["config", "user.email", "sandbox@paperclip.dev"]);
await git(remoteWorkspaceDir, ["add", "-A"]);
await git(remoteWorkspaceDir, ["commit", "-m", "sandbox update"]);
await writeFile(path.join(remoteWorkspaceDir, "tracked.txt"), "remote dirty\n", "utf8");
await writeFile(path.join(remoteWorkspaceDir, "remote-only.txt"), "from sandbox\n", "utf8");
await prepared.restoreWorkspace();
expect((await lstat(path.join(localWorkspaceDir, ".git"))).isFile()).toBe(true);
expect(await git(localWorkspaceDir, ["log", "-1", "--pretty=%s"])).toBe("sandbox update");
await expect(readFile(path.join(localWorkspaceDir, "tracked.txt"), "utf8")).resolves.toBe("remote dirty\n");
await expect(readFile(path.join(localWorkspaceDir, "remote-only.txt"), "utf8")).resolves.toBe("from sandbox\n");
await expect(readFile(path.join(localWorkspaceDir, "deleted.txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(readFile(path.join(localWorkspaceDir, "node_modules", "cache.bin"), "utf8")).resolves.toBe("do not upload\n");
expect(downloadedTars).toHaveLength(1);
const downloadMembers = await listTarMembers(rootDir, "workspace-download-list.tar", downloadedTars[0]!.bytes);
expect(downloadMembers.some((entry) => entry === ".git" || entry.startsWith(".git/"))).toBe(false);
expect(downloadMembers.some((entry) => entry === "node_modules" || entry.startsWith("node_modules/"))).toBe(false);
expect(runtimeStatusPhases).toEqual([
"git_sync",
"config_sync",
"export",
"restore",
"finalize",
]);
});
it("repairs stale host index deletions when the sandbox restores a clean git worktree", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-clean-restore-"));
cleanupDirs.push(rootDir);
const sourceRepoDir = path.join(rootDir, "source-repo");
const localWorkspaceDir = path.join(rootDir, "local-worktree");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(sourceRepoDir, { recursive: true });
await git(sourceRepoDir, ["init"]);
await git(sourceRepoDir, ["checkout", "-b", "main"]);
await git(sourceRepoDir, ["config", "user.name", "Paperclip Test"]);
await git(sourceRepoDir, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(sourceRepoDir, "kept.txt"), "kept\n", "utf8");
await writeFile(path.join(sourceRepoDir, "restored.txt"), "restored\n", "utf8");
await git(sourceRepoDir, ["add", "kept.txt", "restored.txt"]);
await git(sourceRepoDir, ["commit", "-m", "base"]);
await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]);
await git(localWorkspaceDir, ["rm", "restored.txt"]);
expect(await git(localWorkspaceDir, ["status", "--short"])).toContain("D restored.txt");
const missingStatusReads: string[] = [];
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes) => {
await mkdir(path.dirname(remotePath), { recursive: true });
await writeFile(remotePath, Buffer.from(bytes));
},
readFile: async (remotePath) => {
if (remotePath.endsWith("workspace-status.txt")) {
missingStatusReads.push(remotePath);
throw new Error("status file unavailable");
}
return await readFile(remotePath);
},
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
},
};
const prepared = await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
});
expect(await git(remoteWorkspaceDir, ["status", "--short"])).toContain("D restored.txt");
await git(remoteWorkspaceDir, ["reset", "--hard", "HEAD"]);
expect(await git(remoteWorkspaceDir, ["status", "--short"])).toBe("");
await prepared.restoreWorkspace();
await expect(readFile(path.join(localWorkspaceDir, "restored.txt"), "utf8")).resolves.toBe("restored\n");
expect(await git(localWorkspaceDir, ["ls-files", "restored.txt"])).toBe("restored.txt");
expect(await git(localWorkspaceDir, ["status", "--short"])).toBe("");
expect(await git(localWorkspaceDir, ["diff", "--name-status", "HEAD", "--"])).toBe("");
expect(await git(localWorkspaceDir, ["diff", "--cached", "--name-status", "HEAD", "--"])).toBe("");
expect(missingStatusReads).toHaveLength(1);
});
it("does not fail clean restore checks when local working tree changes survive", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-preserved-local-"));
cleanupDirs.push(rootDir);
const sourceRepoDir = path.join(rootDir, "source-repo");
const localWorkspaceDir = path.join(rootDir, "local-worktree");
await mkdir(sourceRepoDir, { recursive: true });
await git(sourceRepoDir, ["init"]);
await git(sourceRepoDir, ["checkout", "-b", "main"]);
await git(sourceRepoDir, ["config", "user.name", "Paperclip Test"]);
await git(sourceRepoDir, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(sourceRepoDir, "kept.txt"), "base\n", "utf8");
await git(sourceRepoDir, ["add", "kept.txt"]);
await git(sourceRepoDir, ["commit", "-m", "base"]);
await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]);
await writeFile(path.join(localWorkspaceDir, "kept.txt"), "local user change\n", "utf8");
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
try {
await resetLocalGitIndexToHead({
localDir: localWorkspaceDir,
checkWorkingTreeClean: true,
});
expect(warnSpy).toHaveBeenCalledWith(
"[paperclip] Workspace restore preserved local working tree changes after clean sandbox restore.",
);
} finally {
warnSpy.mockRestore();
}
await expect(readFile(path.join(localWorkspaceDir, "kept.txt"), "utf8")).resolves.toBe("local user change\n");
expect(await git(localWorkspaceDir, ["diff", "--cached", "--name-status", "HEAD", "--"])).toBe("");
expect(await git(localWorkspaceDir, ["status", "--short"])).toContain("M kept.txt");
});
it("excludes unignored dependency trees from git-backed workspace overlay archives", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-unignored-deps-"));
cleanupDirs.push(rootDir);
const sourceRepoDir = path.join(rootDir, "source-repo");
const localWorkspaceDir = path.join(rootDir, "local-worktree");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(sourceRepoDir, { recursive: true });
await git(sourceRepoDir, ["init"]);
await git(sourceRepoDir, ["checkout", "-b", "main"]);
await git(sourceRepoDir, ["config", "user.name", "Paperclip Test"]);
await git(sourceRepoDir, ["config", "user.email", "test@paperclip.dev"]);
await mkdir(path.join(sourceRepoDir, "src"), { recursive: true });
await writeFile(path.join(sourceRepoDir, "src", "tracked.ts"), "export const tracked = true;\n", "utf8");
await git(sourceRepoDir, ["add", "src/tracked.ts"]);
await git(sourceRepoDir, ["commit", "-m", "base"]);
await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]);
await mkdir(path.join(localWorkspaceDir, "node_modules", "root-package"), { recursive: true });
await mkdir(path.join(localWorkspaceDir, "packages", "ui", "node_modules", "nested-package"), { recursive: true });
await writeFile(path.join(localWorkspaceDir, "node_modules", "root-package", "cache.bin"), "root dependency\n", "utf8");
await writeFile(
path.join(localWorkspaceDir, "packages", "ui", "node_modules", "nested-package", "cache.bin"),
"nested dependency\n",
"utf8",
);
await writeFile(path.join(localWorkspaceDir, "src", "local-only.ts"), "export const local = true;\n", "utf8");
const uploadedTars: { remotePath: string; bytes: Buffer }[] = [];
const downloadedTars: { remotePath: string; bytes: Buffer }[] = [];
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes) => {
await mkdir(path.dirname(remotePath), { recursive: true });
const buffer = Buffer.from(bytes);
if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer });
await writeFile(remotePath, buffer);
},
readFile: async (remotePath) => {
const buffer = await readFile(remotePath);
if (remotePath.endsWith("workspace-download.tar")) downloadedTars.push({ remotePath, bytes: buffer });
return buffer;
},
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
},
};
const prepared = await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
});
const workspaceUpload = uploadedTars.find((entry) => path.posix.basename(entry.remotePath) === "workspace-upload.tar");
expect(workspaceUpload).toBeDefined();
const workspaceMembers = await listTarMembers(rootDir, "unignored-deps-workspace-upload.tar", workspaceUpload!.bytes);
expect(workspaceMembers).toContain("src/local-only.ts");
expect(workspaceMembers.some((entry) => entry === "node_modules" || entry.startsWith("node_modules/"))).toBe(false);
expect(workspaceMembers.some((entry) => entry.includes("/node_modules/") || entry.endsWith("/node_modules"))).toBe(false);
await expect(readFile(path.join(remoteWorkspaceDir, "src", "local-only.ts"), "utf8")).resolves.toBe("export const local = true;\n");
await expect(readFile(path.join(remoteWorkspaceDir, "node_modules", "root-package", "cache.bin"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
await expect(
readFile(path.join(remoteWorkspaceDir, "packages", "ui", "node_modules", "nested-package", "cache.bin"), "utf8"),
).rejects.toMatchObject({ code: "ENOENT" });
await mkdir(path.join(remoteWorkspaceDir, "node_modules", "sandbox-package"), { recursive: true });
await mkdir(path.join(remoteWorkspaceDir, "packages", "ui", "node_modules", "sandbox-package"), { recursive: true });
await writeFile(path.join(remoteWorkspaceDir, "node_modules", "sandbox-package", "cache.bin"), "sandbox root dependency\n", "utf8");
await writeFile(
path.join(remoteWorkspaceDir, "packages", "ui", "node_modules", "sandbox-package", "cache.bin"),
"sandbox nested dependency\n",
"utf8",
);
await writeFile(path.join(remoteWorkspaceDir, "src", "remote-only.ts"), "export const remote = true;\n", "utf8");
await prepared.restoreWorkspace();
await expect(readFile(path.join(localWorkspaceDir, "node_modules", "root-package", "cache.bin"), "utf8")).resolves.toBe("root dependency\n");
await expect(
readFile(path.join(localWorkspaceDir, "packages", "ui", "node_modules", "nested-package", "cache.bin"), "utf8"),
).resolves.toBe("nested dependency\n");
await expect(readFile(path.join(localWorkspaceDir, "src", "remote-only.ts"), "utf8")).resolves.toBe("export const remote = true;\n");
await expect(readFile(path.join(localWorkspaceDir, "node_modules", "sandbox-package", "cache.bin"), "utf8")).rejects.toMatchObject({ code: "ENOENT" });
expect(downloadedTars).toHaveLength(1);
const downloadMembers = await listTarMembers(rootDir, "unignored-deps-workspace-download.tar", downloadedTars[0]!.bytes);
expect(downloadMembers.some((entry) => entry === ".git" || entry.startsWith(".git/"))).toBe(false);
expect(downloadMembers.some((entry) => entry === "node_modules" || entry.startsWith("node_modules/"))).toBe(false);
expect(downloadMembers.some((entry) => entry.includes("/node_modules/") || entry.endsWith("/node_modules"))).toBe(false);
});
it("builds workspace/asset tarballs without a './' self-entry (so untar does not chmod/utime an unowned target dir)", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-tarself-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const localAssetsDir = path.join(rootDir, "local-assets");
await mkdir(path.join(localWorkspaceDir, "src"), { recursive: true });
await mkdir(localAssetsDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "ws\n", "utf8");
await writeFile(path.join(localWorkspaceDir, "src", "main.ts"), "x\n", "utf8");
await writeFile(path.join(localAssetsDir, "asset.txt"), "a\n", "utf8");
// Capture every tar uploaded to the sandbox so we can inspect its members.
const uploadedTars: { remotePath: string; bytes: Buffer }[] = [];
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes) => {
await mkdir(path.dirname(remotePath), { recursive: true });
const buffer = Buffer.from(bytes);
if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer });
await writeFile(remotePath, buffer);
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
},
};
await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
assets: [{ key: "skills", localDir: localAssetsDir }],
});
expect(uploadedTars.length).toBeGreaterThanOrEqual(2);
for (const { remotePath, bytes } of uploadedTars) {
const listPath = path.join(rootDir, `list-${path.basename(remotePath)}`);
await writeFile(listPath, bytes);
const { stdout } = await execFile("tar", ["-tf", listPath], { maxBuffer: 32 * 1024 * 1024 });
const members = stdout.split("\n").map((line) => line.trim()).filter(Boolean);
// The archive must NOT contain a self-entry for the root directory; that is
// what makes tar try to mutate the (possibly unowned) extraction target.
expect(members).not.toContain(".");
expect(members).not.toContain("./");
}
// And the workspace still extracts correctly into an existing target dir.
await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("ws\n");
await expect(readFile(path.join(remoteWorkspaceDir, "src", "main.ts"), "utf8")).resolves.toBe("x\n");
});
it("excludes transient symlinked home dirs from the asset tar while keeping required content", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-home-tmp-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const homeDir = path.join(rootDir, "codex-home");
await mkdir(localWorkspaceDir, { recursive: true });
// Simulate a host Codex binary that a stale `tmp/arg0` symlink points at.
// With followSymlinks the archive would otherwise inline this whole file.
const hostBinary = path.join(rootDir, "codex-host-binary");
const binaryMarker = "HOST_CODEX_BINARY_BYTES";
await writeFile(hostBinary, `${binaryMarker}\n`.repeat(4096), "utf8");
// Required managed-home content that MUST still reach the sandbox.
await mkdir(path.join(homeDir, "skills"), { recursive: true });
await writeFile(path.join(homeDir, "auth.json"), "{\"OPENAI_API_KEY\":\"sk-test\"}\n", "utf8");
await writeFile(path.join(homeDir, "config.toml"), "model = \"gpt\"\n", "utf8");
await writeFile(path.join(homeDir, "skills", "demo.md"), "skill body\n", "utf8");
// Transient dirs holding symlinks to the host binary (the bloat source).
await mkdir(path.join(homeDir, "tmp", "arg0"), { recursive: true });
await mkdir(path.join(homeDir, ".tmp"), { recursive: true });
await symlink(hostBinary, path.join(homeDir, "tmp", "arg0", "codex"));
await symlink(hostBinary, path.join(homeDir, ".tmp", "codex"));
const uploadedTars: { remotePath: string; bytes: Buffer }[] = [];
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes) => {
await mkdir(path.dirname(remotePath), { recursive: true });
const buffer = Buffer.from(bytes);
if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer });
await writeFile(remotePath, buffer);
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
},
};
const prepared = await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "codex",
client,
workspaceLocalDir: localWorkspaceDir,
assets: [{
key: "home",
localDir: homeDir,
followSymlinks: true,
exclude: ["tmp", ".tmp"],
}],
});
const homeTar = uploadedTars.find(({ remotePath }) => path.basename(remotePath) === "home-upload.tar");
expect(homeTar).toBeDefined();
const members = await listTarMembers(rootDir, "home-members.tar", homeTar!.bytes);
// Transient symlink trees must be filtered out entirely.
expect(members.some((entry) => entry === "tmp" || entry.startsWith("tmp/"))).toBe(false);
expect(members.some((entry) => entry === ".tmp" || entry.startsWith(".tmp/"))).toBe(false);
// Required managed-home content must survive.
expect(members).toContain("auth.json");
expect(members).toContain("config.toml");
expect(members.some((entry) => entry === "skills/demo.md")).toBe(true);
// The host binary bytes must not have been inlined into the upload.
expect(homeTar!.bytes.includes(Buffer.from(binaryMarker))).toBe(false);
// The extracted sandbox home keeps required content and omits the transient dirs.
await expect(readFile(path.join(prepared.assetDirs.home, "auth.json"), "utf8"))
.resolves.toBe("{\"OPENAI_API_KEY\":\"sk-test\"}\n");
await expect(readFile(path.join(prepared.assetDirs.home, "skills", "demo.md"), "utf8"))
.resolves.toBe("skill body\n");
await expect(lstat(path.join(prepared.assetDirs.home, "tmp"))).rejects.toMatchObject({ code: "ENOENT" });
await expect(lstat(path.join(prepared.assetDirs.home, ".tmp"))).rejects.toMatchObject({ code: "ENOENT" });
});
it("emits throttled, labeled upload and restore progress with direction and percentages", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-progress-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
const localAssetsDir = path.join(rootDir, "local-assets");
await mkdir(localWorkspaceDir, { recursive: true });
await mkdir(localAssetsDir, { recursive: true });
await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace\n", "utf8");
await writeFile(path.join(localAssetsDir, "skill.md"), "skill\n", "utf8");
// Drive byte progress in 100 fine (1%) increments so the throttle has many
// chances to emit; the reporter must collapse them to ~one line per 10% step.
const driveProgress = async (
total: number,
onProgress: ((done: number, total: number | null) => void | Promise<void>) | undefined,
) => {
if (!onProgress) return;
for (let i = 1; i <= 100; i++) {
await onProgress(Math.floor((total * i) / 100), total);
}
};
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes, options) => {
await mkdir(path.dirname(remotePath), { recursive: true });
const buffer = Buffer.from(bytes);
await writeFile(remotePath, buffer);
await driveProgress(buffer.byteLength, options?.onProgress);
},
readFile: async (remotePath, options) => {
const buffer = await readFile(remotePath);
await driveProgress(buffer.byteLength, options?.onProgress);
return buffer;
},
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
},
};
const lines: string[] = [];
const prepared = await prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
assets: [{ key: "skills", localDir: localAssetsDir }],
onProgress: (line) => {
lines.push(line);
},
});
const uploadWorkspaceLines = lines.filter((line) => line.includes("Syncing workspace to sandbox"));
const uploadAssetLines = lines.filter((line) => line.includes("Syncing skills to sandbox"));
expect(uploadWorkspaceLines.length).toBeGreaterThan(0);
expect(uploadAssetLines.length).toBeGreaterThan(0);
// 100 reported increments must be throttled to at most ~one line per 10% step.
expect(uploadWorkspaceLines.length).toBeLessThanOrEqual(11);
// Reaches 100% and shows the MB breakdown.
expect(uploadWorkspaceLines.some((line) => line.includes("100%"))).toBe(true);
expect(uploadWorkspaceLines.every((line) => /\(\d+\.\d\/\d+\.\d MB\)/.test(line))).toBe(true);
await prepared.restoreWorkspace();
const restoreLines = lines.filter((line) => line.includes("Restoring workspace from sandbox"));
expect(restoreLines.length).toBeGreaterThan(0);
expect(restoreLines.length).toBeLessThanOrEqual(11);
expect(restoreLines.some((line) => line.includes("100%"))).toBe(true);
});
it("creates a valid empty workspace tarball when the local workspace is empty", async () => {
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-empty-"));
cleanupDirs.push(rootDir);
const localWorkspaceDir = path.join(rootDir, "local-workspace");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
const client: SandboxManagedRuntimeClient = {
makeDir: async (remotePath) => {
await mkdir(remotePath, { recursive: true });
},
writeFile: async (remotePath, bytes) => {
await mkdir(path.dirname(remotePath), { recursive: true });
await writeFile(remotePath, Buffer.from(bytes));
},
readFile: async (remotePath) => await readFile(remotePath),
listFiles: async () => [],
remove: async (remotePath) => {
await rm(remotePath, { recursive: true, force: true });
},
run: async (command) => {
await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 });
},
};
await expect(
prepareSandboxManagedRuntime({
spec: {
transport: "sandbox",
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
},
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
}),
).resolves.toBeDefined();
});
});