fix(runtime): route sandbox git-bundle export through native syncOut (#11749)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work
> - Sandbox-managed runtimes move files between a host and an isolated
sandbox
> - The git-bundle export path reads the full bundle into host memory
> - The workspace restore path already uses the provider native
`syncOut` transfer
> - This pull request uses `syncOut` for bundle export and keeps
`readFile` as a fallback
> - The change reduces host buffering and exposes the transfer to
provider tracing

## Linked Issues or Issue Description

**What happened?**

The sandbox git-bundle export used `client.readFile` even when the
provider supported native `syncOut`. The path buffered the full bundle
in host memory and used a chunked base64 command loop.

**Expected behavior**

The export should use one confined native file transfer when the
provider supports `syncOut`. Providers without that capability should
keep the existing `readFile` fallback.

**Steps to reproduce**

1. Prepare a sandbox-managed runtime with native `syncOut` support.
2. Export the sandbox git bundle.
3. Inspect the sync operations and file reads.
4. Confirm that the bundle uses one native file mapping and that the
status file still uses `readFile`.

**Paperclip version or commit**

Commit `3fc88d14be7133867897e87838276e15aba675fd`.

**Deployment mode**

Built from source. The change applies to sandbox-managed runtime
execution.

**Additional context**

This PR contains a focused bug fix. No public issue matched this change
during the duplicate search.

## What Changed

- Route bundle export through `nativeSyncOut` when the provider supports
it.
- Keep the existing `readFile` fallback for providers without native
sync support.
- Confine the native bundle mapping with `assertSyncOperationsConfined`.
- Cover the native path, fallback path, retry path, and confinement
checks with unit tests.

## Verification

- `npx vitest run
packages/adapter-utils/src/sandbox-managed-runtime.test.ts` — 52 tests
passed.
- `npx tsc --noEmit` in `packages/adapter-utils` — passed.
- The PR CI workflow must pass before merge.

## Risks

Low risk. The native path runs only when the provider advertises
`syncOut`. The existing `readFile` path remains available as a fallback.
Native transfer progress reports only start and finish events.

## Model Used

OpenAI GPT-5 Codex. Exact runtime model ID: GPT-5 Codex. Context window:
not exposed in this run. Capabilities used: code review, shell tools,
GitHub CLI, and Paperclip API coordination.

## 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:
Nicky Leach 2026-08-19 16:50:04 -07:00 committed by GitHub
parent b8a76081ec
commit d5416fde9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 288 additions and 4 deletions

View File

@ -3200,3 +3200,262 @@ describe("sandbox managed runtime outbound coordinator", () => {
await restore;
});
});
// The bundle export inside the workspace restore task selects its outbound
// transport by the client. A client with native `syncOut` copies the bundle
// straight into the host restore temp directory through one `kind: "file"`
// mapping. A client without native `syncOut` reads the bundle back through
// `readFile`. These tests build a git-backed workspace and assert the transport
// branch, the confinement guard, and the full-bundle retry.
describe("sandbox git-bundle export transport", () => {
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);
}
});
// Copy a directory tree and drop each entry whose name matches an exclude
// term. This models a native provider `syncOut` for a directory mapping: it
// honors `exclude`, so `.git`, `node_modules`, and the runtime root never
// reach the host restore temp directory. The tar fallback drops the same set.
async function copyDirectoryWithExclude(
sourceDir: string,
targetDir: string,
exclude: string[] | undefined,
): Promise<void> {
const excludeNames = new Set((exclude ?? []).map((entry) => entry.replace(/\/$/, "")));
await mkdir(targetDir, { recursive: true });
const entries = await readdir(sourceDir, { withFileTypes: true });
for (const entry of entries) {
if (excludeNames.has(entry.name)) continue;
const source = path.join(sourceDir, entry.name);
const target = path.join(targetDir, entry.name);
if (entry.isDirectory()) {
await copyDirectoryWithExclude(source, target, exclude);
} else if (entry.isSymbolicLink()) {
await symlink(await fsPromises.readlink(source), target);
} else {
await writeFile(target, await readFile(source));
}
}
}
interface TransportCapture {
syncOutOperations: SandboxSyncOperation[];
readFilePaths: string[];
}
// Build a git-backed managed-runtime client. `native` toggles the outbound
// `syncOut`. The client records every `syncOut` operation and every `readFile`
// remote path, so a test can prove which transport moved the bundle.
function makeTransportClient(native: boolean, capture: TransportCapture): SandboxManagedRuntimeClient {
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) => {
capture.readFilePaths.push(remotePath);
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 });
},
};
attachFallbackSyncIn(client);
if (native) {
client.syncOut = async (operations) => {
for (const operation of operations) {
capture.syncOutOperations.push(operation);
for (const mapping of operation.files) {
if (mapping.kind === "directory") {
await copyDirectoryWithExclude(mapping.sourcePath, mapping.targetPath, mapping.exclude);
} else {
await mkdir(path.dirname(mapping.targetPath), { recursive: true });
await writeFile(mapping.targetPath, await readFile(mapping.sourcePath));
}
}
}
return {
operations: operations.map((operation) => ({
operationId: operation.operationId,
filesTransferred: operation.files.length,
bytesTransferred: 0,
})),
};
};
}
return client;
}
const gitSpec = (remoteWorkspaceDir: string) => ({
transport: "sandbox" as const,
provider: "test",
sandboxId: "sandbox-1",
remoteCwd: remoteWorkspaceDir,
timeoutMs: 30_000,
apiKey: null,
});
// Create a git repository with one commit and a linked worktree. Return the
// host worktree directory and the sandbox workspace directory the prepare step
// fills through a shallow standalone clone.
async function setupGitBackedWorkspace(prefix: string): Promise<{
localWorkspaceDir: string;
remoteWorkspaceDir: string;
}> {
const rootDir = await mkdtemp(path.join(os.tmpdir(), prefix));
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 git(sourceRepoDir, ["add", "-A"]);
await git(sourceRepoDir, ["commit", "-m", "base"]);
await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]);
return { localWorkspaceDir, remoteWorkspaceDir };
}
// Advance the sandbox history by one commit that also adds a new file. The
// caller runs the restore after this, so the export moves this new history.
async function commitInSandbox(remoteWorkspaceDir: string): Promise<void> {
await git(remoteWorkspaceDir, ["config", "user.name", "Paperclip Sandbox"]);
await git(remoteWorkspaceDir, ["config", "user.email", "sandbox@paperclip.dev"]);
await writeFile(path.join(remoteWorkspaceDir, "remote-only.txt"), "from sandbox\n", "utf8");
await git(remoteWorkspaceDir, ["add", "-A"]);
await git(remoteWorkspaceDir, ["commit", "-m", "sandbox update"]);
}
function bundleFileMappings(capture: TransportCapture) {
return capture.syncOutOperations
.flatMap((operation) => operation.files)
.filter((mapping) => path.posix.basename(mapping.sourcePath) === "git-delta.bundle");
}
it("moves the bundle through one native syncOut file mapping, never through readFile", async () => {
const capture: TransportCapture = { syncOutOperations: [], readFilePaths: [] };
const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-bundle-native-");
const client = makeTransportClient(true, capture);
const prepared = await prepareSandboxManagedRuntime({
spec: gitSpec(remoteWorkspaceDir),
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
});
await commitInSandbox(remoteWorkspaceDir);
await prepared.restoreWorkspace();
// The restore imported the sandbox commit and its new file.
expect(await git(localWorkspaceDir, ["log", "-1", "--pretty=%s"])).toBe("sandbox update");
await expect(readFile(path.join(localWorkspaceDir, "remote-only.txt"), "utf8")).resolves.toBe("from sandbox\n");
// The bundle rode exactly one native `kind: "file"` mapping into the host
// restore temp directory; `readFile` never touched the bundle.
const mappings = bundleFileMappings(capture);
expect(mappings).toHaveLength(1);
expect(mappings[0]!.kind).toBe("file");
expect(path.posix.basename(mappings[0]!.targetPath)).toBe("git-delta.bundle");
expect(capture.readFilePaths.some((remotePath) => remotePath.endsWith("git-delta.bundle"))).toBe(false);
// The small status file stays on `readFile`; the change does not migrate it.
expect(capture.readFilePaths.some((remotePath) => remotePath.endsWith("workspace-status.txt"))).toBe(true);
});
it("reads the bundle through readFile when the client has no native syncOut", async () => {
const capture: TransportCapture = { syncOutOperations: [], readFilePaths: [] };
const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-bundle-fallback-");
const client = makeTransportClient(false, capture);
const prepared = await prepareSandboxManagedRuntime({
spec: gitSpec(remoteWorkspaceDir),
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
});
await commitInSandbox(remoteWorkspaceDir);
await prepared.restoreWorkspace();
expect(await git(localWorkspaceDir, ["log", "-1", "--pretty=%s"])).toBe("sandbox update");
await expect(readFile(path.join(localWorkspaceDir, "remote-only.txt"), "utf8")).resolves.toBe("from sandbox\n");
// Without native `syncOut` the fallback path is unchanged: no sync operation
// ran and the bundle came back through `readFile`.
expect(client.syncOut).toBeUndefined();
expect(capture.syncOutOperations).toHaveLength(0);
expect(capture.readFilePaths.some((remotePath) => remotePath.endsWith("git-delta.bundle"))).toBe(true);
});
it("retries the full bundle through the native branch when the delta misses its prerequisite", async () => {
const capture: TransportCapture = { syncOutOperations: [], readFilePaths: [] };
const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-bundle-retry-"));
cleanupDirs.push(rootDir);
// A standalone host repository, so the test controls object reachability. A
// linked worktree shares the source object store, and the boundary commit
// stays reachable there; a standalone repository lets `gc` prune it.
const localWorkspaceDir = path.join(rootDir, "local-repo");
const remoteWorkspaceDir = path.join(rootDir, "remote-workspace");
await mkdir(localWorkspaceDir, { recursive: true });
await git(localWorkspaceDir, ["init"]);
await git(localWorkspaceDir, ["checkout", "-b", "work"]);
await git(localWorkspaceDir, ["config", "user.name", "Paperclip Test"]);
await git(localWorkspaceDir, ["config", "user.email", "test@paperclip.dev"]);
await writeFile(path.join(localWorkspaceDir, "tracked.txt"), "base\n", "utf8");
await git(localWorkspaceDir, ["add", "-A"]);
await git(localWorkspaceDir, ["commit", "-m", "base"]);
const firstCommit = await git(localWorkspaceDir, ["rev-parse", "HEAD"]);
await writeFile(path.join(localWorkspaceDir, "tracked.txt"), "second\n", "utf8");
await git(localWorkspaceDir, ["add", "-A"]);
await git(localWorkspaceDir, ["commit", "-m", "second"]);
const stagedBase = await git(localWorkspaceDir, ["rev-parse", "HEAD"]);
const client = makeTransportClient(true, capture);
const prepared = await prepareSandboxManagedRuntime({
spec: gitSpec(remoteWorkspaceDir),
adapterKey: "test-adapter",
client,
workspaceLocalDir: localWorkspaceDir,
});
await commitInSandbox(remoteWorkspaceDir);
const sandboxHead = await git(remoteWorkspaceDir, ["rev-parse", "HEAD"]);
// The host drops below the staged base commit and prunes it. The delta
// bundle names that boundary commit as a prerequisite the host no longer
// holds, so the import fails and forces the full-bundle retry.
await git(localWorkspaceDir, ["reset", "--hard", firstCommit]);
await git(localWorkspaceDir, ["reflog", "expire", "--expire=now", "--all"]);
await git(localWorkspaceDir, ["gc", "--prune=now"]);
await expect(git(localWorkspaceDir, ["cat-file", "-e", `${stagedBase}^{commit}`])).rejects.toThrow();
await prepared.restoreWorkspace();
// Two bundle exports rode native file mappings: the delta attempt and the
// full-bundle retry. `readFile` never moved the bundle on either attempt.
const mappings = bundleFileMappings(capture);
expect(mappings).toHaveLength(2);
expect(mappings.every((mapping) => mapping.kind === "file")).toBe(true);
expect(capture.readFilePaths.some((remotePath) => remotePath.endsWith("git-delta.bundle"))).toBe(false);
// The full bundle was self-contained: the host repository now holds the
// sandbox head commit.
await expect(git(localWorkspaceDir, ["cat-file", "-e", `${sandboxHead}^{commit}`])).resolves.toBe("");
});
});

View File

@ -1269,9 +1269,35 @@ export async function prepareSandboxManagedRuntime(input: {
undefined,
{ sink: input.onRuntimeProgress, phase: "export" },
);
const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options);
const bundleBuffer = toBuffer(bundleBytes);
await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength);
if (nativeSyncOut) {
// Native outbound: the provider copies the bundle straight from
// the sandbox into the host restore temp directory. This maps to
// one `kind: "file"` mapping. The host does not buffer the full
// bundle in RAM and does not move the bytes through the base64
// read loop. The git import step below reads the bundle from
// `localBundlePath`. The provider transfer reports no byte counts,
// so the "Exporting git history" progress degrades to
// start-and-finish only (mirrors the workspace restore below).
const operations: SandboxSyncOperation[] = [{
operationId: nextSyncOperationId(),
files: [{
sourcePath: remoteGitBundle,
targetPath: localBundlePath,
kind: "file",
}],
}];
assertSyncOperationsConfined(operations, {
sourceRoots: [runtimeRootDir],
targetRoots: [tempDir],
});
await input.client.syncOut!(operations);
await gitExport.finish(0, 0);
} else {
const bundleBytes = await input.client.readFile(remoteGitBundle, gitExport.options);
const bundleBuffer = toBuffer(bundleBytes);
await gitExport.finish(bundleBuffer.byteLength, bundleBuffer.byteLength);
await fs.writeFile(localBundlePath, bundleBuffer);
}
await input.client.remove(remoteGitBundle).catch(() => undefined);
if (!forceFullBundle) {
remoteWorkspaceStatus = await input.client.readFile(remoteWorkspaceStatusPath)
@ -1280,7 +1306,6 @@ export async function prepareSandboxManagedRuntime(input: {
remoteWorkspaceStatus = remoteWorkspaceStatus === "clean" ? "clean" : "dirty";
await input.client.remove(remoteWorkspaceStatusPath).catch(() => undefined);
}
await fs.writeFile(localBundlePath, bundleBuffer);
return fetchGitBundleIntoLocalRef({
localDir: input.workspaceLocalDir,
bundlePath: localBundlePath,