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 os from "node:os"; import path from "node:path"; import { execFile as execFileCallback, spawn } from "node:child_process"; import { promisify } from "node:util"; import { afterEach, describe, expect, it, vi } from "vitest"; import { resetLocalGitIndexToHead, runLocalGit, setExpensiveWorkspaceGitExecutor, WORKSPACE_GIT_SCAN_SATURATED_CODE, } from "./git-workspace-sync.js"; import { assertSyncOperationsConfined, escapeTarExcludeLiteral, mirrorDirectory, prepareSandboxManagedRuntime, REFERENCED_SOURCE_IGNORE_FAILURE_REASONS, resolveReferencedSourceIgnore, type PreparedSandboxManagedRuntime, type ReferencedSourceIgnoreResolution, type SandboxManagedRuntimeAsset, type SandboxManagedRuntimeClient, type SandboxSyncOperation, type SandboxSyncResult, } from "./sandbox-managed-runtime.js"; import { prepareCommandManagedRuntime, type CommandManagedRuntimeRunner, } from "./command-managed-runtime.js"; import { SYNC_OPERATION_CONCURRENCY_LIMIT } from "./sync-operation-schedule.js"; import { createRuntimeSpanRunner, getActiveStepContext, measureStartupStep, type RuntimeSpanRunner, type StartupSpan, type StartupTraceContext, type StartupTracer, } from "./acpx-engine/startup-timing.js"; import type { RunProcessResult } from "./server-utils.js"; function toArrayBuffer(bytes: Buffer): ArrayBuffer { return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer; } // Sum the file sizes under a directory, recursively. Test-only: it stands in // for a real provider's own byte count on a `kind: "directory"` mapping, so a // fake `syncIn`/`syncOut` can report a real, non-zero `bytesTransferred`. async function directoryByteSize(dir: string): Promise { let total = 0; const entries = await readdir(dir, { withFileTypes: true }); for (const entry of entries) { const entryPath = path.join(dir, entry.name); if (entry.isDirectory()) { total += await directoryByteSize(entryPath); } else if (entry.isFile()) { total += (await stat(entryPath)).size; } } return total; } // Give a bare fake client a `syncIn` that reproduces the non-native base64-tar // FALLBACK (place each file mapping via `writeFile`, then run the operation's // ordered `postUploadCommands` fail-fast via `run`) — byte-for-byte the prior // inline `writeFile`+`run` sequence, exercised through the unified seam. Inbound // staging uses only `kind: "file"` mappings. function attachFallbackSyncIn(client: SandboxManagedRuntimeClient, timeoutMs = 30_000): void { client.syncIn = async (operations: SandboxSyncOperation[]): Promise => { const resultOperations: SandboxSyncResult["operations"] = []; for (const operation of operations) { let filesTransferred = 0; let bytesTransferred = 0; for (const mapping of operation.files) { const bytes = await readFile(mapping.sourcePath); await client.makeDir(path.posix.dirname(mapping.targetPath)); if (mapping.mode != null) { const staged = `${mapping.targetPath}.pcstage`; await client.writeFile(staged, toArrayBuffer(bytes)); await client.run( `chmod ${(mapping.mode & 0o7777).toString(8)} '${staged}' && mv -f '${staged}' '${mapping.targetPath}'`, { timeoutMs }, ); } else { await client.writeFile(mapping.targetPath, toArrayBuffer(bytes)); } filesTransferred += 1; bytesTransferred += bytes.byteLength; } for (const command of operation.postUploadCommands ?? []) { await client.run(command.command, { timeoutMs: command.timeoutMs ?? timeoutMs }); } resultOperations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred }); } return { operations: resultOperations }; }; } // A NATIVE-simulating `syncIn`: it records the operations for assertion and // materializes files directly (never through the client's `writeFile`/`run`), so // a test can prove the orchestrator delegates entirely to `syncIn` (0 direct // `writeFile`/`run` execs). Post-upload commands still run in-sandbox (via `sh`), // modeling a provider that honors `postUploadCommands` after `uploadFiles`. function attachNativeRecordingSyncIn( client: SandboxManagedRuntimeClient, captured: SandboxSyncOperation[], ): void { client.syncIn = async (operations: SandboxSyncOperation[]): Promise => { const resultOperations: SandboxSyncResult["operations"] = []; for (const operation of operations) { captured.push(operation); let filesTransferred = 0; let bytesTransferred = 0; for (const mapping of operation.files) { const bytes = await readFile(mapping.sourcePath); await mkdir(path.posix.dirname(mapping.targetPath), { recursive: true }); await writeFile(mapping.targetPath, bytes); if (mapping.mode != null) await fsPromises.chmod(mapping.targetPath, mapping.mode); filesTransferred += 1; bytesTransferred += bytes.byteLength; } for (const command of operation.postUploadCommands ?? []) { await execFile("sh", ["-c", command.command], { maxBuffer: 32 * 1024 * 1024 }); } resultOperations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred }); } return { operations: resultOperations }; }; } // A capturing `syncIn` that records every operation for assertion and // materializes file AND directory mappings. A directory mapping (a referenced // project) uses `mirrorDirectory`, so a test can assert the advisory `access` // intent on directory mappings as well as file mappings. It reports a real // `bytesTransferred` for a directory mapping too, from a post-mirror byte // count, so a test can assert on the emitted progress line. function attachCapturingSyncIn( client: SandboxManagedRuntimeClient, captured: SandboxSyncOperation[], ): void { client.syncIn = async (operations: SandboxSyncOperation[]): Promise => { const resultOperations: SandboxSyncResult["operations"] = []; for (const operation of operations) { captured.push(operation); let filesTransferred = 0; let bytesTransferred = 0; for (const mapping of operation.files) { await mkdir(path.posix.dirname(mapping.targetPath), { recursive: true }); if (mapping.kind === "directory") { await mirrorDirectory(mapping.sourcePath, mapping.targetPath); bytesTransferred += await directoryByteSize(mapping.targetPath); } else { const bytes = await readFile(mapping.sourcePath); await writeFile(mapping.targetPath, bytes); if (mapping.mode != null) await fsPromises.chmod(mapping.targetPath, mapping.mode); bytesTransferred += bytes.byteLength; } filesTransferred += 1; } for (const command of operation.postUploadCommands ?? []) { await execFile("sh", ["-c", command.command], { maxBuffer: 32 * 1024 * 1024 }); } resultOperations.push({ operationId: operation.operationId, filesTransferred, bytesTransferred }); } return { operations: resultOperations }; }; } vi.mock("node:fs", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, promises: { ...actual.promises, chmod: vi.fn(actual.promises.chmod), rename: vi.fn(actual.promises.rename), }, }; }); const execFile = promisify(execFileCallback); async function git(cwd: string, args: string[]): Promise { const { stdout } = await execFile("git", ["-C", cwd, ...args], { maxBuffer: 32 * 1024 * 1024, }); return stdout.trim(); } // A minimal committed Git repository, for the referenced-project ignore tests. async function initGitRepo(repoDir: string): Promise { await mkdir(repoDir, { recursive: true }); await git(repoDir, ["init", "-q"]); await git(repoDir, ["config", "user.name", "Paperclip Test"]); await git(repoDir, ["config", "user.email", "test@paperclip.dev"]); await writeFile(path.join(repoDir, "README.md"), "root\n", "utf8"); await git(repoDir, ["add", "README.md"]); await git(repoDir, ["commit", "-qm", "base"]); } // A `CommandManagedRuntimeRunner` that runs real shell commands on the host // filesystem (host FS stands in for the sandbox FS), exposing no native // `syncIn` — staging rides the real base64/tar fallback, the same transport a // provider without native sync uses. Unlike the in-file fake clients, this // fallback DOES build a real tarball via `createTarballFromDirectory` for a // `directory`-kind mapping, so it is the one that genuinely applies `exclude`. function makeInlineSpawnRunner(): CommandManagedRuntimeRunner { return { execute: (input) => new Promise((resolve) => { const startedAt = new Date().toISOString(); const command = input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command; const child = spawn(command, input.args ?? [], { cwd: input.cwd, env: { ...process.env, ...input.env } }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); child.on("error", () => resolve({ exitCode: 127, signal: null, timedOut: false, stdout, stderr, pid: null, startedAt })); child.on("close", (code) => resolve({ exitCode: code ?? 0, signal: null, timedOut: false, stdout, stderr, pid: child.pid ?? null, startedAt })); if (input.stdin != null) child.stdin.write(input.stdin); child.stdin.end(); }), }; } // Stage ONE referenced project end-to-end (real tar fallback, real `exclude` // filtering) through the full `prepareCommandManagedRuntime` seam, for the // referenced-project ignore regression tests. async function stageOneReferencedProject( referencedDir: string, ignoreResolution: ReferencedSourceIgnoreResolution, ): Promise { const rootDir = path.dirname(referencedDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); await mkdir(localWorkspaceDir, { recursive: true }); await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8"); return await prepareCommandManagedRuntime({ runner: makeInlineSpawnRunner(), spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, adapterKey: "test-adapter", workspaceLocalDir: localWorkspaceDir, additionalSources: [{ localPath: referencedDir, projectId: "proj", ignoreResolution }], }); } async function listTarMembers(rootDir: string, name: string, bytes: Buffer): Promise { 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); } // Build a filesystem-backed managed-runtime client. The host tarball path runs // unchanged; the client just materializes the mappings on the local disk, so a // pack-span test needs no provider. function makeFilesystemClient(): 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) => 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 }); }, }; attachFallbackSyncIn(client); return client; } // One recorded span from the fake tracer. `parentName` is the name of the span // that the start context carried, so a test can assert the parent relationship. interface RecordedSpan { name: string; parentName: string | null; ended: boolean; attributes: Record; } // A fake trace context that records every span and its parent by name. It // satisfies the structural `StartupTraceContext` contract, so the real // `createRuntimeSpanRunner` and `measureStartupStep` drive it unchanged. The // opaque parent token is the parent's `RecordedSpan`, so a child span reads its // parent name from the start context. function createRecordingTraceContext(): { traceContext: StartupTraceContext; spans: RecordedSpan[]; } { const spans: RecordedSpan[] = []; const byHandle = new WeakMap(); const tracer: StartupTracer = { startSpan(name, options, context) { const parent = context as RecordedSpan | undefined; const record: RecordedSpan = { name, parentName: parent?.name ?? null, ended: false, attributes: { ...(options?.attributes ?? {}) }, }; spans.push(record); const handle: StartupSpan = { setAttribute(key, value) { record.attributes[key] = value; }, setStatus() {}, end() { record.ended = true; }, }; byHandle.set(handle, record); return handle; }, }; const traceContext: StartupTraceContext = { tracer, contextWithSpan: (span) => byHandle.get(span), }; return { traceContext, spans }; } 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("applies file mode on a staged sibling before renaming into place", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-copy-mode-")); cleanupDirs.push(rootDir); const sourceDir = path.join(rootDir, "source"); const targetDir = path.join(rootDir, "target"); const relativePath = path.join("nested", "script.sh"); const sourcePath = path.join(sourceDir, relativePath); const targetPath = path.join(targetDir, relativePath); await mkdir(path.dirname(sourcePath), { recursive: true }); await writeFile(sourcePath, "#!/bin/sh\necho hello\n", { mode: 0o600 }); await mkdir(targetDir, { recursive: true }); const chmodMock = vi.mocked(fsPromises.chmod); const renameMock = vi.mocked(fsPromises.rename); chmodMock.mockClear(); renameMock.mockClear(); await mirrorDirectory(sourceDir, targetDir); await expect(readFile(targetPath, "utf8")).resolves.toBe("#!/bin/sh\necho hello\n"); expect(chmodMock).toHaveBeenCalledTimes(1); expect(renameMock).toHaveBeenCalledTimes(1); expect(chmodMock.mock.calls[0]?.[0]).toContain(".paperclip-copy."); expect(chmodMock.mock.calls[0]?.[0]).not.toBe(targetPath); expect(chmodMock.mock.invocationCallOrder[0]).toBeLessThan(renameMock.mock.invocationCallOrder[0]); }); it("cleans up a staged sibling when chmod fails before rename", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-copy-cleanup-")); cleanupDirs.push(rootDir); const sourceDir = path.join(rootDir, "source"); const targetDir = path.join(rootDir, "target"); const relativePath = path.join("nested", "script.sh"); const sourcePath = path.join(sourceDir, relativePath); const targetPath = path.join(targetDir, relativePath); await mkdir(path.dirname(sourcePath), { recursive: true }); await writeFile(sourcePath, "#!/bin/sh\necho hello\n", { mode: 0o600 }); await mkdir(targetDir, { recursive: true }); const chmodMock = vi.mocked(fsPromises.chmod); const renameMock = vi.mocked(fsPromises.rename); chmodMock.mockClear(); renameMock.mockClear(); chmodMock.mockImplementationOnce(async () => { throw new Error("chmod failed"); }); await expect(mirrorDirectory(sourceDir, targetDir)).rejects.toThrow(/chmod failed/); expect(chmodMock).toHaveBeenCalledTimes(1); expect(renameMock).not.toHaveBeenCalled(); const stagedPath = chmodMock.mock.calls[0]?.[0]; expect(stagedPath).toContain(".paperclip-copy."); await expect(readFile(stagedPath, "utf8")).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(targetPath, "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[] = []; attachFallbackSyncIn(client); 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(expect.arrayContaining([ "config_sync:Syncing workspace to environment", "config_sync:Syncing runtime assets to environment", "restore:Restoring workspace from environment", "finalize:Finalizing workspace", ])); expect(runtimeStatuses).toEqual(expect.arrayContaining([ expect.stringMatching(/^config_sync:Syncing workspace to environment: 100% \(\d+\.\d\/\d+\.\d MB\)$/), expect.stringMatching(/^config_sync:Syncing skills to environment: 100% \(\d+\.\d\/\d+\.\d MB\)$/), expect.stringMatching(/^restore:Restoring workspace from environment: 100% \(\d+\.\d\/\d+\.\d MB\)$/), ])); expect(runtimeStatuses.at(-1)).toBe("finalize:Finalizing workspace"); }); it("falls back to the host-known byte count when the provider reports 0 for inbound workspace sync", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-zero-report-")); cleanupDirs.push(rootDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); await mkdir(localWorkspaceDir, { recursive: true }); // Sizeable content, well above the 0.1 MB rounding step, so a fallback bug // that stays at 0 is distinguishable from a small transfer that would // still round down to "0.0 MB". await writeFile(path.join(localWorkspaceDir, "large.bin"), Buffer.alloc(300 * 1024, "a")); const client = makeFilesystemClient(); const realSyncIn = client.syncIn!; // Simulate a provider that under-reports: it moves the real bytes but // returns 0 in `bytesTransferred`, exactly like a buggy or minimal plugin. client.syncIn = async (operations) => { const result = await realSyncIn(operations); return { operations: result.operations.map((operation) => ({ ...operation, bytesTransferred: 0 })), }; }; const lines: string[] = []; await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, onProgress: (line) => { lines.push(line); }, }); await expect(readFile(path.join(remoteWorkspaceDir, "large.bin"))).resolves.toHaveLength(300 * 1024); const workspaceLines = lines.filter((line) => line.includes("Syncing workspace to environment")); expect(workspaceLines.length).toBeGreaterThan(0); // Even though the provider reported 0, the line still shows the real, // host-known workspace size, from the caller-supplied fallback. expect(workspaceLines.some((line) => /\(\d+\.\d\/\d+\.\d MB\)/.test(line))).toBe(true); expect(workspaceLines.some((line) => line.includes("(0.0/0.0 MB)"))).toBe(false); }); it.each(["workspace", "git-workspace"])( "rejects an asset key that collides with the reserved %s archive name", async (reservedKey) => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-asset-key-")); 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"); const client = makeFilesystemClient(); await expect( prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, assets: [{ key: reservedKey, localDir: localAssetsDir }], }), ).rejects.toThrow(/collides with a reserved runtime archive name/); // The reserved-key guard fails before any workspace or asset archive is // built, so nothing lands in the remote workspace directory. await expect(readdir(remoteWorkspaceDir)).rejects.toMatchObject({ code: "ENOENT" }); }, ); it.each(["skills/nested", "skills\\nested", "..", "../escape"])( "rejects an asset key that is not a simple path segment: %s", async (unsafeKey) => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-asset-key-")); 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"); const client = makeFilesystemClient(); await expect( prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, assets: [{ key: unsafeKey, localDir: localAssetsDir }], }), ).rejects.toThrow(/is not a simple path segment/); }, ); 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 driveProgress = async ( total: number, onProgress: ((done: number, total: number | null) => void | Promise) | undefined, ) => { if (!onProgress) return; await onProgress(Math.max(1, Math.floor(total / 2)), total); await onProgress(total, 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); if (remotePath.endsWith("-upload.tar")) uploadedTars.push({ remotePath, bytes: buffer }); await writeFile(remotePath, buffer); await driveProgress(buffer.byteLength, options?.onProgress); }, readFile: async (remotePath, options) => { const buffer = await readFile(remotePath); if (remotePath.endsWith("workspace-download.tar")) downloadedTars.push({ remotePath, bytes: buffer }); 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 runtimeStatuses: Array<{ phase: string; message: string }> = []; attachFallbackSyncIn(client); 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) => { runtimeStatuses.push({ phase: status.phase, message: status.message }); }, }); 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(runtimeStatuses.map((status) => status.phase)).toEqual(expect.arrayContaining([ "git_sync", "config_sync", "export", "restore", "finalize", ])); // Git history and workspace overlay sync as ONE merged operation, so a single // transfer-progress event rides the config_sync (workspace) phase. The git_sync // phase still emits its plain status message (asserted by the arrayContaining // check above). expect(runtimeStatuses.some((status) => ( status.phase === "config_sync" && /^Syncing workspace to environment: 100% \(\d+\.\d\/\d+\.\d MB\)$/.test(status.message) ))).toBe(true); expect(runtimeStatuses.some((status) => ( status.phase === "export" && /^Exporting git history from environment: 100% \(\d+\.\d\/\d+\.\d MB\)$/.test(status.message) ))).toBe(true); }); 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 }); }, }; attachFallbackSyncIn(client); 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 }); }, }; attachFallbackSyncIn(client); 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("excludes an anchor-workspace ignored file whose name has leading and trailing whitespace from the staged tree", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-ignored-whitespace-")); cleanupDirs.push(rootDir); const workspaceLocalDir = path.join(rootDir, "workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); await initGitRepo(workspaceLocalDir); // A double-wildcard pattern avoids the separate rule that Git trims an // unescaped trailing space in a .gitignore PATTERN itself; the padding // under test lives in the matched FILE name, proving the anchor `splitNul` // parser keeps it instead of trimming it away and missing the exclude. const ignoredName = " ignored padded "; await writeFile(path.join(workspaceLocalDir, ".gitignore"), "*ignored*padded*\n", "utf8"); await writeFile(path.join(workspaceLocalDir, ignoredName), "TOKEN=abc\n", "utf8"); await writeFile(path.join(workspaceLocalDir, "kept.txt"), "kept\n", "utf8"); 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 }); }, }; attachFallbackSyncIn(client); await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client, workspaceLocalDir, }); const workspaceUpload = uploadedTars.find((entry) => path.posix.basename(entry.remotePath) === "workspace-upload.tar"); expect(workspaceUpload).toBeDefined(); const members = await listTarMembers(rootDir, "ignored-whitespace-workspace-upload.tar", workspaceUpload!.bytes); expect(members).not.toContain(ignoredName); expect(members).toContain("kept.txt"); }); 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/downloaded through the sandbox so we can inspect its members. 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 }); }, }; attachFallbackSyncIn(client); 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 }], }); 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"); await prepared.restoreWorkspace(); expect(downloadedTars).toHaveLength(1); const downloadMembers = await listTarMembers(rootDir, "workspace-download-list.tar", downloadedTars[0]!.bytes); expect(downloadMembers).not.toContain("."); expect(downloadMembers).not.toContain("./"); }); 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 }); }, }; attachFallbackSyncIn(client); 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) | 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[] = []; attachFallbackSyncIn(client); 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 environment")); const uploadAssetLines = lines.filter((line) => line.includes("Syncing skills to environment")); 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 environment")); expect(restoreLines.length).toBeGreaterThan(0); expect(restoreLines.length).toBeLessThanOrEqual(11); expect(restoreLines.some((line) => line.includes("100%"))).toBe(true); }); it("creates valid empty workspace tarballs when the 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 downloadedTars: { remotePath: string; bytes: Buffer }[] = []; const runCommands: 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) => { 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) => { runCommands.push(command); await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, }; attachFallbackSyncIn(client); 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, }); await prepared.restoreWorkspace(); expect(downloadedTars).toHaveLength(1); const members = await listTarMembers(rootDir, "empty-workspace-download.tar", downloadedTars[0]!.bytes); expect(members).toEqual([]); const emptyArchiveCommand = runCommands.find((command) => command.includes("dd if=/dev/zero")); expect(emptyArchiveCommand).toBeDefined(); expect(emptyArchiveCommand).not.toContain("/dev/null"); }); it("provisions a contribution-less asset via a plain tar extract and restores it as a no-op", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-default-asset-")); 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, "plain.txt"), "plain asset\n", "utf8"); const stagedWrites: string[] = []; const runCommands: string[] = []; const client: SandboxManagedRuntimeClient = { makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, writeFile: async (remotePath, bytes) => { await mkdir(path.dirname(remotePath), { recursive: true }); if (!remotePath.endsWith("-upload.tar")) stagedWrites.push(path.basename(remotePath)); 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) => { runCommands.push(command); await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, }; attachFallbackSyncIn(client); 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, // No `provision` / `restore` on the asset: it must ride the default path. assets: [{ key: "plain", localDir: localAssetsDir }], }); // Extracted through the default `tar -xf` path. await expect(readFile(path.join(prepared.assetDirs.plain, "plain.txt"), "utf8")).resolves.toBe("plain asset\n"); // A contribution-less asset stages no extra files beyond its own tar. expect(stagedWrites.filter((name) => name.includes("plain"))).toEqual([]); // The extract command is the generic tar path, not an adapter-specific script. const assetExtract = runCommands.find((command) => command.includes(`${path.posix.basename(prepared.assetDirs.plain)}-upload.tar`)); expect(assetExtract).toBeDefined(); expect(assetExtract).toContain("tar -xf"); expect(assetExtract).not.toMatch(/\.sh|\.cjs/); // Restore is a clean no-op for a contribution-less asset (no throw, asset dir untouched). await expect(prepared.restoreWorkspace()).resolves.toBeUndefined(); await expect(readFile(path.join(prepared.assetDirs.plain, "plain.txt"), "utf8")).resolves.toBe("plain asset\n"); }); it("round-trips a non-codex asset through generic provision + restore contributions", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-seam-")); 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, "seed.txt"), "seed\n", "utf8"); 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 }); }, }; // A minimal shell quoter local to the test's custom extract command; the seam // itself carries no adapter knowledge — the fake asset supplies everything. const q = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`; const restored: string[] = []; const stagedContentSeen: string[] = []; attachFallbackSyncIn(client); const prepared = await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "generic-adapter", client, workspaceLocalDir: localWorkspaceDir, assets: [{ key: "widget", localDir: localAssetsDir, provision: { stageFiles: [{ name: "widget-helper.txt", contents: "helper-bytes\n" }], // Extract the asset AND consume the staged helper file, proving both // stageFiles and postUploadCommand flow through the core generically. postUploadCommand: ({ assetTarPath, assetDir, runtimeRootDir }) => `rm -rf ${q(assetDir)} && mkdir -p ${q(assetDir)} && ` + `tar -xf ${q(assetTarPath)} -C ${q(assetDir)} && rm -f ${q(assetTarPath)} && ` + `cp ${q(path.posix.join(runtimeRootDir, "widget-helper.txt"))} ${q(path.posix.join(assetDir, "helper.copied.txt"))}`, }, restore: async ({ assetDir, readFile: readRemote }) => { const bytes = await readRemote(path.posix.join(assetDir, "refreshed.txt")); restored.push(bytes.toString("utf8")); }, }], }); // provision: the asset's own content extracted... await expect(readFile(path.join(prepared.assetDirs.widget, "seed.txt"), "utf8")).resolves.toBe("seed\n"); // ...the staged helper file was written to the runtime root and consumed by the custom extract command. await expect(readFile(path.join(prepared.assetDirs.widget, "helper.copied.txt"), "utf8")).resolves.toBe("helper-bytes\n"); stagedContentSeen.push("provisioned"); // Simulate the sandbox refreshing a file inside the asset dir, then restore. await writeFile(path.join(prepared.assetDirs.widget, "refreshed.txt"), "refreshed-by-sandbox\n", "utf8"); await prepared.restoreWorkspace(); // restore contribution was invoked with a working remote readFile against assetDir. expect(restored).toEqual(["refreshed-by-sandbox\n"]); expect(stagedContentSeen).toEqual(["provisioned"]); }); it("rejects a provision stageFile.name that is not a simple basename", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-traversal-")); 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, "seed.txt"), "seed\n", "utf8"); const writtenPaths: string[] = []; const client: SandboxManagedRuntimeClient = { makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, writeFile: async (remotePath, bytes) => { writtenPaths.push(remotePath); 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 }); }, }; attachFallbackSyncIn(client); // A compromised adapter supplying a traversal name must be rejected before // the core ever writes outside the runtime root. for (const maliciousName of ["../evil.txt", "..", "nested/child.txt", "back\\slash.txt", "../../etc/passwd"]) { writtenPaths.length = 0; await expect( prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "generic-adapter", client, workspaceLocalDir: localWorkspaceDir, assets: [{ key: "widget", localDir: localAssetsDir, provision: { stageFiles: [{ name: maliciousName, contents: "payload\n" }], }, }], }), ).rejects.toThrow(/must be a simple basename/); // The guard fires before the offending write, so nothing landed under the runtime root. expect(writtenPaths.some((p) => p.endsWith("evil.txt") || p.endsWith("passwd") || p.endsWith("child.txt"))).toBe(false); } }); it("routes a custom-provisioned asset through a single syncIn operation with its post-upload command (native runner → 0 direct writeFile/run)", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-native-asset-")); 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, "seed.txt"), "seed\n", "utf8"); // A native runner delegates every staging step to `syncIn`; the orchestrator // must make NO direct `writeFile`/`run` exec. These record any leak. const directWrites: string[] = []; const directRuns: string[] = []; const client: SandboxManagedRuntimeClient = { makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, writeFile: async (remotePath, bytes) => { directWrites.push(remotePath); 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) => { directRuns.push(command); await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, }; const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); const q = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`; const prepared = await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "generic-adapter", client, workspaceLocalDir: localWorkspaceDir, assets: [{ key: "widget", localDir: localAssetsDir, provision: { stageFiles: [{ name: "widget-helper.sh", contents: "#!/bin/sh\ntar -xf \"$2\" -C \"$1\"\n" }], // A bespoke post-upload command that consumes the staged helper — proves // the custom command (not a plain default `tar -xf`) rides syncIn. postUploadCommand: ({ assetTarPath, assetDir, runtimeRootDir }) => `rm -rf ${q(assetDir)} && mkdir -p ${q(assetDir)} && ` + `sh ${q(path.posix.join(runtimeRootDir, "widget-helper.sh"))} ${q(assetDir)} ${q(assetTarPath)} && ` + `rm -f ${q(assetTarPath)}`, }, }], }); // The orchestrator delegated everything to syncIn: no direct exec/writeFile. expect(directWrites).toEqual([]); expect(directRuns).toEqual([]); // Exactly one operation carries the asset: the asset tar + the staged helper // as `files`, and the bespoke command as the ordered post-upload command. const assetOp = captured.find((op) => op.files.some((mapping) => mapping.targetPath.endsWith("widget-upload.tar")), ); expect(assetOp).toBeDefined(); const targets = assetOp!.files.map((mapping) => path.posix.basename(mapping.targetPath)).sort(); expect(targets).toEqual(["widget-helper.sh", "widget-upload.tar"]); expect(assetOp!.files.every((mapping) => mapping.kind === "file")).toBe(true); expect(assetOp!.postUploadCommands).toHaveLength(1); expect(assetOp!.postUploadCommands![0].command).toContain("widget-helper.sh"); expect(assetOp!.postUploadCommands![0].command).not.toBe( `rm -rf ${q(path.posix.join(prepared.runtimeRootDir, "widget"))} && mkdir -p ${q(path.posix.join(prepared.runtimeRootDir, "widget"))}`, ); // The asset tar carries the asset directory as its read-write destination, // because the extract command fills that directory, not the staging archive. const assetTarMapping = assetOp!.files.find((mapping) => mapping.targetPath.endsWith("widget-upload.tar")); expect(assetTarMapping?.access).toBe("rw"); expect(assetTarMapping?.writablePath).toBe(prepared.assetDirs.widget); // The staged helper file is a read-only input that the command consumes, so // it is `access: "ro"` and never joins the writable set. const stageMapping = assetOp!.files.find((mapping) => mapping.targetPath.endsWith("widget-helper.sh")); expect(stageMapping?.access).toBe("ro"); expect(stageMapping?.writablePath).toBeUndefined(); // The asset actually materialized through the native seam. await expect(readFile(path.join(prepared.assetDirs.widget, "seed.txt"), "utf8")).resolves.toBe("seed\n"); }); it("stages git and workspace via syncIn preserving .paperclip-runtime (native runner → 0 direct writeFile/run)", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-native-git-")); cleanupDirs.push(rootDir); const sourceRepoDir = path.join(rootDir, "source-repo"); const localWorkspaceDir = path.join(rootDir, "local-workspace"); 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, "tracked.txt"), "tracked\n", "utf8"); await git(sourceRepoDir, ["add", "tracked.txt"]); await git(sourceRepoDir, ["commit", "-m", "base"]); await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]); // Pre-seed the sandbox with a `.paperclip-runtime` dir that MUST survive. await mkdir(path.join(remoteWorkspaceDir, ".paperclip-runtime"), { recursive: true }); await writeFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "keep.txt"), "keep\n", "utf8"); const directWrites: string[] = []; const directRuns: string[] = []; const client: SandboxManagedRuntimeClient = { makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, writeFile: async (remotePath, bytes) => { directWrites.push(remotePath); 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) => { directRuns.push(command); await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, }; const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); 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, }); // Delegated entirely to syncIn. expect(directWrites).toEqual([]); expect(directRuns).toEqual([]); // One merged operation carries BOTH the git-history and workspace-overlay tars // as two `file` mappings, each with its extract as an ordered post-command. expect(captured).toHaveLength(1); const op = captured[0]; const byBase = (base: string) => op.files.find((mapping) => path.posix.basename(mapping.targetPath) === base); expect(byBase("git-workspace-upload.tar")).toBeDefined(); expect(byBase("workspace-upload.tar")).toBeDefined(); expect(op.files.every((mapping) => mapping.kind === "file")).toBe(true); // The first post-upload command extracts the git history and preserves // `.paperclip-runtime` while replacing the rest of the tree (wipe-except-preserved). const gitCommand = op.postUploadCommands![0].command; expect(gitCommand).toContain(".paperclip-runtime"); expect(gitCommand).toContain("tar -xf"); // The pre-seeded runtime dir survived the git+workspace staging. await expect( readFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "keep.txt"), "utf8"), ).resolves.toBe("keep\n"); await expect(readFile(path.join(remoteWorkspaceDir, "tracked.txt"), "utf8")).resolves.toBe("tracked\n"); expect(prepared.workspaceRemoteDir).toBe(remoteWorkspaceDir); }); it("the workspace wipe command preserves in-flight sync scratch tarballs (.paperclip-upload-*)", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-scratch-shape-")); cleanupDirs.push(rootDir); const sourceRepoDir = path.join(rootDir, "source-repo"); const localWorkspaceDir = path.join(rootDir, "local-workspace"); 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, "tracked.txt"), "tracked\n", "utf8"); await git(sourceRepoDir, ["add", "tracked.txt"]); await git(sourceRepoDir, ["commit", "-m", "base"]); await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]); 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 }); }, }; const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, }); // The wipe `find` runs before the extract. It must preserve the daytona // scratch prefix so a concurrent referenced-project upload survives the wipe. const wipeCommand = captured[0].postUploadCommands![0].command; expect(wipeCommand).toContain("find "); expect(wipeCommand).toContain("! -name '.paperclip-upload-*'"); }); it("the workspace wipe keeps an in-flight scratch tarball at the root but removes a stale sibling", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-scratch-race-")); cleanupDirs.push(rootDir); const sourceRepoDir = path.join(rootDir, "source-repo"); const localWorkspaceDir = path.join(rootDir, "local-workspace"); 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, "tracked.txt"), "tracked\n", "utf8"); await git(sourceRepoDir, ["add", "tracked.txt"]); await git(sourceRepoDir, ["commit", "-m", "base"]); await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]); // Pre-seed the sandbox root. `.paperclip-upload-test.tar` simulates a // concurrent referenced-project scratch tarball in flight; `stale-junk.txt` // is an unrelated child that the wipe must remove. await mkdir(remoteWorkspaceDir, { recursive: true }); await writeFile(path.join(remoteWorkspaceDir, ".paperclip-upload-test.tar"), "scratch\n", "utf8"); await writeFile(path.join(remoteWorkspaceDir, "stale-junk.txt"), "junk\n", "utf8"); 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 }); }, }; const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, }); // The real `find` wipe ran through `sh -c`. The scratch tarball survived and // the unrelated sibling did not. await expect( readFile(path.join(remoteWorkspaceDir, ".paperclip-upload-test.tar"), "utf8"), ).resolves.toBe("scratch\n"); await expect( readFile(path.join(remoteWorkspaceDir, "stale-junk.txt"), "utf8"), ).rejects.toThrow(); }); it("issues one merged syncIn operation for a git-backed workspace stage-sync with two ordered extract commands", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-merged-git-")); cleanupDirs.push(rootDir); const sourceRepoDir = path.join(rootDir, "source-repo"); const localWorkspaceDir = path.join(rootDir, "local-workspace"); 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, "tracked.txt"), "tracked\n", "utf8"); await git(sourceRepoDir, ["add", "tracked.txt"]); await git(sourceRepoDir, ["commit", "-m", "base"]); await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]); // Pre-seed the sandbox with a `.paperclip-runtime` dir that MUST survive. await mkdir(path.join(remoteWorkspaceDir, ".paperclip-runtime"), { recursive: true }); await writeFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "keep.txt"), "keep\n", "utf8"); 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 }); }, }; const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); // Count how many times `syncIn` is invoked so the merge collapses the two // workspace staging steps into a single native round trip. let syncInCallCount = 0; const recordingSyncIn = client.syncIn!; client.syncIn = async (operations) => { syncInCallCount += 1; return recordingSyncIn(operations); }; await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, }); // One `syncIn` call carrying exactly one operation for the whole workspace. expect(syncInCallCount).toBe(1); expect(captured).toHaveLength(1); const op = captured[0]; // Both host tars ride the one operation as two `file` mappings. const byBase = (base: string) => op.files.find((mapping) => path.posix.basename(mapping.targetPath) === base); const gitMapping = byBase("git-workspace-upload.tar"); const overlayMapping = byBase("workspace-upload.tar"); expect(gitMapping).toBeDefined(); expect(overlayMapping).toBeDefined(); expect(op.files).toHaveLength(2); expect(op.files.every((mapping) => mapping.kind === "file")).toBe(true); // Both tar targets live under `.paperclip-runtime`, so the git extract's wipe // (which preserves `.paperclip-runtime`) cannot delete the overlay tar before // the overlay extract runs. for (const mapping of op.files) { expect(mapping.targetPath).toContain("/.paperclip-runtime/"); } // Two ordered extract commands: git history first (wipe-except-preserved), // overlay second (merge, no wipe). No deleted paths in this clean worktree. const commands = op.postUploadCommands ?? []; expect(commands).toHaveLength(2); expect(commands[0].command).toContain("git-workspace-upload.tar"); expect(commands[0].command).toContain(".paperclip-runtime"); expect(commands[0].command).toContain("find "); expect(commands[1].command).toContain("workspace-upload.tar"); expect(commands[1].command).not.toContain("git-workspace-upload.tar"); expect(commands[1].command).not.toContain("find "); // The pre-seeded runtime dir survived and the workspace overlay applied. await expect( readFile(path.join(remoteWorkspaceDir, ".paperclip-runtime", "keep.txt"), "utf8"), ).resolves.toBe("keep\n"); await expect(readFile(path.join(remoteWorkspaceDir, "tracked.txt"), "utf8")).resolves.toBe("tracked\n"); }); it("the merged workspace confine guard covers both tar mappings (escape in either trips it)", () => { const runtimeRoot = "/home/daytona/paperclip-workspace/.paperclip-runtime/test-adapter"; const tempRoot = "/tmp/paperclip-sandbox-sync-abc"; const gitMapping = { sourcePath: `${tempRoot}/git-workspace.tar`, targetPath: `${runtimeRoot}/git-workspace-upload.tar`, kind: "file" as const, }; const overlayMapping = { sourcePath: `${tempRoot}/workspace.tar`, targetPath: `${runtimeRoot}/workspace-upload.tar`, kind: "file" as const, }; const roots = { sourceRoots: [tempRoot], targetRoots: [runtimeRoot] }; // A confined merged operation with both tar mappings passes the guard. expect(() => assertSyncOperationsConfined( [{ operationId: "merged", files: [gitMapping, overlayMapping] }], roots, ), ).not.toThrow(); // A `..` target escape in the overlay mapping trips the guard, so the whole // merged operation is rejected before any transfer. expect(() => assertSyncOperationsConfined( [{ operationId: "merged", files: [ gitMapping, { ...overlayMapping, targetPath: `${runtimeRoot}/../../etc/workspace-upload.tar` }, ], }], roots, ), ).toThrow(/escapes its confinement root|not a confined absolute path/); // An absolute-path source escape in the git mapping trips the guard too. expect(() => assertSyncOperationsConfined( [{ operationId: "merged", files: [{ ...gitMapping, sourcePath: "/etc/passwd" }, overlayMapping], }], roots, ), ).toThrow(/escapes its confinement root|not a confined absolute path/); }); // Regression lock: a representative `codex_local` start stages its inbound bytes // as TWO `syncIn` operations. The git-history and workspace-overlay tars share // ONE merged operation (one native `uploadFiles` round-trip that carries both // tars, with the two extract commands as ordered `postUploadCommands`); the // managed Codex `home` asset (auth.json merge) is the second operation. Every // inbound step is routed through `client.syncIn`, with no separate // custom-provision diversion. Assert the collapsed round-trip count so a future // change that re-inlines a `writeFile`+`run` sequence — or splits the merged // workspace operation back into two — fails loudly here instead of silently // regressing the start path. it("collapses a representative codex_local start to two syncIn round-trips: one merged workspace op plus the asset op", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-sandbox-codex-roundtrip-")); cleanupDirs.push(rootDir); const sourceRepoDir = path.join(rootDir, "source-repo"); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); const homeDir = path.join(rootDir, "codex-home"); // Git-backed workspace → git history + workspace overlay share one merged op. 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, "tracked.txt"), "tracked\n", "utf8"); await git(sourceRepoDir, ["add", "tracked.txt"]); await git(sourceRepoDir, ["commit", "-m", "base"]); await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]); // Managed Codex home with an auth.json that a custom post-upload command // merges in-sandbox — the credential path is routed onto native uploadFiles. await mkdir(homeDir, { 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"); // A native runner delegates every staging step to `syncIn`; ANY direct // writeFile/run exec is a collapse regression. const directWrites: string[] = []; const directRuns: string[] = []; const client: SandboxManagedRuntimeClient = { makeDir: async (remotePath) => { await mkdir(remotePath, { recursive: true }); }, writeFile: async (remotePath, bytes) => { directWrites.push(remotePath); 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) => { directRuns.push(command); await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, }; const captured: SandboxSyncOperation[] = []; attachNativeRecordingSyncIn(client, captured); const q = (value: string) => `'${value.replace(/'/g, `'\"'\"'`)}'`; 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, provision: { stageFiles: [{ name: "home-merge.sh", contents: "#!/bin/sh\ntar -xf \"$2\" -C \"$1\"\n" }], postUploadCommand: ({ assetTarPath, assetDir, runtimeRootDir }) => `mkdir -p ${q(assetDir)} && ` + `sh ${q(path.posix.join(runtimeRootDir, "home-merge.sh"))} ${q(assetDir)} ${q(assetTarPath)} && ` + `rm -f ${q(assetTarPath)}`, }, }], }); // The orchestrator delegated everything to syncIn: no re-inlined writeFile/run. expect(directWrites).toEqual([]); expect(directRuns).toEqual([]); // The collapsed count: exactly two inbound round-trips — the merged workspace // op (git history + overlay) and the home asset op. expect(captured).toHaveLength(2); const hasBase = (op: SandboxSyncOperation, base: string) => op.files.some((mapping) => path.posix.basename(mapping.targetPath) === base); const workspaceOp = captured.find((op) => hasBase(op, "workspace-upload.tar")); const homeOp = captured.find((op) => hasBase(op, "home-upload.tar")); expect(workspaceOp).toBeDefined(); expect(homeOp).toBeDefined(); // The merged workspace op carries BOTH the git-history and overlay tars, with // both extract commands as ordered post-upload commands (git first, overlay // second). The two tars ride one native uploadFiles round-trip. expect(hasBase(workspaceOp!, "git-workspace-upload.tar")).toBe(true); expect(workspaceOp!.files).toHaveLength(2); expect((workspaceOp!.postUploadCommands ?? []).length).toBeGreaterThanOrEqual(2); // Every operation is a native uploadFiles (all `file` mappings) whose // extract/merge rides as an ordered provider-executed post-upload command. for (const op of captured) { expect(op.files.length).toBeGreaterThanOrEqual(1); expect(op.files.every((mapping) => mapping.kind === "file")).toBe(true); expect(op.postUploadCommands ?? []).not.toHaveLength(0); } // Operation ids are distinct, so "2 operations" is 2 real round-trips. expect(new Set(captured.map((op) => op.operationId)).size).toBe(2); // The credential asset actually materialized through the native seam. await expect(readFile(path.join(prepared.assetDirs.home, "auth.json"), "utf8")) .resolves.toBe("{\"OPENAI_API_KEY\":\"sk-test\"}\n"); }); it("authors the advisory access intent rw on workspace, git, and asset inbound mappings", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-access-rw-")); cleanupDirs.push(rootDir); const sourceRepoDir = path.join(rootDir, "source-repo"); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); const assetDir = path.join(rootDir, "asset-home"); // A git-backed workspace produces both a git-history tar and an overlay tar. 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, "tracked.txt"), "tracked\n", "utf8"); await git(sourceRepoDir, ["add", "tracked.txt"]); await git(sourceRepoDir, ["commit", "-m", "base"]); await git(sourceRepoDir, ["worktree", "add", "-b", "work", localWorkspaceDir, "HEAD"]); await mkdir(assetDir, { recursive: true }); await writeFile(path.join(assetDir, "config.toml"), "model = \"gpt\"\n", "utf8"); 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 }); }, }; const captured: SandboxSyncOperation[] = []; attachCapturingSyncIn(client, captured); 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: "home", localDir: assetDir }], }); const findMapping = (base: string) => captured .flatMap((op) => op.files) .find((mapping) => path.posix.basename(mapping.targetPath) === base); // The workspace, git-history, and asset destinations receive read-write bytes, // so the author marks each mapping `access: "rw"`. expect(findMapping("workspace-upload.tar")?.access).toBe("rw"); expect(findMapping("git-workspace-upload.tar")?.access).toBe("rw"); expect(findMapping("home-upload.tar")?.access).toBe("rw"); // Each tar mapping uploads a staging archive under the runtime root, so its // `targetPath` is not the read-write destination. `writablePath` names the // directory that the post-upload extract command fills: the workspace // directory for the workspace and git tars, and the asset directory for the // asset tar. const remoteAssetDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "test-adapter", "home"); expect(findMapping("workspace-upload.tar")?.writablePath).toBe(remoteWorkspaceDir); expect(findMapping("git-workspace-upload.tar")?.writablePath).toBe(remoteWorkspaceDir); expect(findMapping("home-upload.tar")?.writablePath).toBe(remoteAssetDir); }); it("authors the advisory access intent ro on referenced-project inbound mappings", async () => { const flagKey = "PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC"; const priorFlag = process.env[flagKey]; process.env[flagKey] = "1"; try { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-access-ro-")); cleanupDirs.push(rootDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); const referencedDir = path.join(rootDir, "referenced-project"); await mkdir(localWorkspaceDir, { recursive: true }); await mkdir(referencedDir, { recursive: true }); await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8"); await writeFile(path.join(referencedDir, "notes.md"), "reference\n", "utf8"); 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 }); }, }; const captured: SandboxSyncOperation[] = []; attachCapturingSyncIn(client, captured); await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, additionalSources: [{ localPath: referencedDir, projectId: "proj-first", ignoreResolution: { kind: "other" } }], }); const referencedMapping = captured .flatMap((op) => op.files) .find((mapping) => path.posix.basename(mapping.targetPath) === "project-proj-first"); // A referenced project is a read-only tree, so the author marks it `access: "ro"`. expect(referencedMapping).toBeDefined(); expect(referencedMapping?.kind).toBe("directory"); expect(referencedMapping?.access).toBe("ro"); } finally { if (priorFlag === undefined) delete process.env[flagKey]; else process.env[flagKey] = priorFlag; } }); it("reports the real transferred bytes for a referenced project's inbound staging", async () => { const flagKey = "PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC"; const priorFlag = process.env[flagKey]; process.env[flagKey] = "1"; try { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-project-bytes-")); cleanupDirs.push(rootDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); const referencedDir = path.join(rootDir, "referenced-project"); await mkdir(localWorkspaceDir, { recursive: true }); await mkdir(referencedDir, { recursive: true }); await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8"); // A referenced project has no host tarball to `fs.stat`, so its progress // line depends entirely on the transport's own `bytesTransferred`. Give it // real, sizeable content (well above the 0.1 MB rounding step), so a bug // that keeps the line at 0 stays distinguishable from a correctly-reported // small transfer that would still round down to "0.0 MB". await writeFile(path.join(referencedDir, "notes.md"), Buffer.alloc(300 * 1024, "a")); 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 }); }, }; attachCapturingSyncIn(client, []); const lines: string[] = []; await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-1", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, additionalSources: [{ localPath: referencedDir, projectId: "proj-first", ignoreResolution: { kind: "other" } }], onProgress: (line) => { lines.push(line); }, }); const projectLines = lines.filter((line) => line.includes("Syncing project-proj-first to environment")); expect(projectLines.length).toBeGreaterThan(0); // The transfer landed real bytes, so the terminal line carries the actual // byte total and a 100% completion, not the "0.0 MB" that a discarded // sync result would show. expect(projectLines.some((line) => line.includes("100%"))).toBe(true); expect(projectLines.every((line) => /\(\d+\.\d\/\d+\.\d MB\)/.test(line))).toBe(true); expect(projectLines.some((line) => line.includes("(0.0/0.0 MB)"))).toBe(false); } finally { if (priorFlag === undefined) delete process.env[flagKey]; else process.env[flagKey] = priorFlag; } }); it("keeps the sandbox runtime core free of Codex-specific string literals", async () => { const coreSource = await readFile(new URL("./sandbox-managed-runtime.ts", import.meta.url), "utf8"); // The seam must be generic: no adapter (Codex) knowledge may live in the core. expect(coreSource).not.toMatch(/codex/i); expect(coreSource).not.toMatch(/auth\.json/i); expect(coreSource).not.toMatch(/merge-extract|merge-decision/i); }); // End-to-end: drive the WHOLE prepare path — `prepareCommandManagedRuntime` // building the client, syncing the anchor workspace, then staging the // referenced projects — through a runner that runs real shell commands on the // host filesystem (host FS stands in for the sandbox FS). The runner exposes no // native syncIn, so staging rides the base64/tar fallback, the same transport a // provider without native sync uses. In production the kill-switch // `PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC` gates whether run prep resolves any // referenced projects (OFF ⇒ none reach this layer). Enable it in-test only to // model the ON scenario, and prove multi-project isolation plus one-failure // isolation end-to-end. it("stages multiple referenced projects into isolated sandbox dirs end-to-end, skipping a failing source", async () => { const flagKey = "PAPERCLIP_MULTI_PROJECT_WORKSPACE_SYNC"; const priorFlag = process.env[flagKey]; process.env[flagKey] = "1"; try { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-e2e-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 content\n", "utf8"); // Two real referenced-project checkouts (one with a nested file) plus a // deliberately-missing source between them. const first = path.join(rootDir, "referenced-first"); const second = path.join(rootDir, "referenced-second"); await mkdir(path.join(first, "docs"), { recursive: true }); await mkdir(second, { recursive: true }); await writeFile(path.join(first, "docs", "guide.md"), "first guide\n", "utf8"); await writeFile(path.join(second, "notes.md"), "second notes\n", "utf8"); const runner: CommandManagedRuntimeRunner = { execute: (input) => new Promise((resolve) => { const startedAt = new Date().toISOString(); const command = input.command === "sh" ? "/bin/sh" : input.command === "bash" ? "/bin/bash" : input.command; const child = spawn(command, input.args ?? [], { cwd: input.cwd, env: { ...process.env, ...input.env } }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf8"); }); child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf8"); }); child.on("error", () => resolve({ exitCode: 127, signal: null, timedOut: false, stdout, stderr, pid: null, startedAt })); child.on("close", (code) => resolve({ exitCode: code ?? 0, signal: null, timedOut: false, stdout, stderr, pid: child.pid ?? null, startedAt })); if (input.stdin != null) child.stdin.write(input.stdin); child.stdin.end(); }), }; const prepared = await prepareCommandManagedRuntime({ runner, spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, adapterKey: "test-adapter", workspaceLocalDir: localWorkspaceDir, additionalSources: [ { localPath: first, projectId: "proj-first", ignoreResolution: { kind: "other" } }, { localPath: path.join(rootDir, "referenced-missing"), projectId: "proj-missing", ignoreResolution: { kind: "other" } }, { localPath: second, projectId: "proj-second", ignoreResolution: { kind: "other" } }, ], }); const runtimeRootDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "test-adapter"); // The anchor workspace synced normally and stays byte-identical. await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("anchor content\n"); // Each healthy referenced project landed in its OWN isolated dir; the // missing one is skipped, not fatal. expect(Object.keys(prepared.additionalSourceDirs).sort()).toEqual(["proj-first", "proj-second"]); expect(prepared.additionalSourceDirs["proj-first"]).toBe(path.posix.join(runtimeRootDir, "project-proj-first")); expect(prepared.additionalSourceDirs["proj-second"]).toBe(path.posix.join(runtimeRootDir, "project-proj-second")); expect(prepared.additionalSourceDirs["proj-missing"]).toBeUndefined(); // The skipped project is a first-class per-project failure outcome, not only a warning, so the // run can count it in the requested-vs-synced accounting. The two healthy projects do not // appear as failures. expect(prepared.additionalSourceFailures.map((failure) => failure.projectId)).toEqual([ "proj-missing", ]); expect(prepared.additionalSourceFailures[0]!.error.length).toBeGreaterThan(0); await expect(readFile(path.join(prepared.additionalSourceDirs["proj-first"], "docs", "guide.md"), "utf8")) .resolves.toBe("first guide\n"); await expect(readFile(path.join(prepared.additionalSourceDirs["proj-second"], "notes.md"), "utf8")) .resolves.toBe("second notes\n"); // Neither project's tree leaked into the anchor workspace or into the other // project's dir. await expect(readFile(path.join(remoteWorkspaceDir, "notes.md"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(path.join(prepared.additionalSourceDirs["proj-first"], "notes.md"), "utf8")).rejects .toMatchObject({ code: "ENOENT" }); await expect(readFile(path.join(runtimeRootDir, "project-proj-missing"), "utf8")).rejects .toMatchObject({ code: "ENOENT" }); } finally { if (priorFlag === undefined) delete process.env[flagKey]; else process.env[flagKey] = priorFlag; } }); describe("resolveReferencedSourceIgnore", () => { it("re-relativizes root-relative ignored paths to a nested localPath", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-nested-")); cleanupDirs.push(rootDir); const repo = path.join(rootDir, "repo"); await initGitRepo(repo); // An ignored entry OUTSIDE the referenced project's localPath, and two // ignored entries inside it (one top-level, one nested). await writeFile(path.join(repo, ".gitignore"), "outside-secret.env\npackages/app/secret.env\npackages/app/build/\n", "utf8"); await writeFile(path.join(repo, "outside-secret.env"), "outside\n", "utf8"); const localPath = path.join(repo, "packages", "app"); // Commit a tracked file under `localPath` first. Otherwise the whole // `packages/` directory is untracked, and `git status` collapses it to // one `packages/` line instead of reporting entries inside it // individually — the fixture needs the individual entries. await mkdir(localPath, { recursive: true }); await writeFile(path.join(localPath, "index.ts"), "export {};\n", "utf8"); await git(repo, ["add", "packages/app/index.ts"]); await git(repo, ["commit", "-qm", "add app"]); await mkdir(path.join(localPath, "build"), { recursive: true }); await writeFile(path.join(localPath, "secret.env"), "TOKEN=abc\n", "utf8"); await writeFile(path.join(localPath, "build", "out.js"), "artifact\n", "utf8"); const resolution = await resolveReferencedSourceIgnore(localPath); // Only the entries under `localPath` apply, re-relativized to it — the // sibling `outside-secret.env` never appears, and the prefix // `packages/app/` is stripped. expect(resolution).toEqual({ kind: "git", ignoredPaths: ["build", "secret.env"] }); }); it("keeps today's fixed excludes for a non-Git source", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-nongit-")); cleanupDirs.push(rootDir); const plainDir = path.join(rootDir, "plain-project"); await mkdir(plainDir, { recursive: true }); await writeFile(path.join(plainDir, "file.txt"), "body\n", "utf8"); await expect(resolveReferencedSourceIgnore(plainDir)).resolves.toEqual({ kind: "other" }); }); it("fails closed on a real Git error instead of returning an unfiltered result", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-fail-")); cleanupDirs.push(rootDir); const repo = path.join(rootDir, "repo"); await initGitRepo(repo); // Corrupt the index so `git rev-parse --show-toplevel` still succeeds but // `git status --ignored` fails with a real error (not "not a git repository"). await writeFile(path.join(repo, ".git", "index"), "not a valid index\n", "utf8"); const resolution = await resolveReferencedSourceIgnore(repo); // The reason is the fixed category, never the caught error's own message // (which would embed `repo`, an absolute host path). expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed }); }); // Nadia's required probes: none of these three example absolute paths — // an ordinary POSIX path, a home directory, and a Windows path — may ever // reach `reason`, however they arrive (a caught Git error, or a raw // toplevel string that makes `localPath` a non-descendant). const SENSITIVE_PATH_PROBES = ["/srv/alice/project", "/home/alice/project", "C:\\Users\\alice\\project"]; for (const sensitivePath of SENSITIVE_PATH_PROBES) { it(`redacts a caught Git error embedding ${sensitivePath} to the fixed category`, async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-redact-caught-")); cleanupDirs.push(rootDir); const repo = path.join(rootDir, "repo"); await initGitRepo(repo); try { setExpensiveWorkspaceGitExecutor(async (input) => { if (input.operation === "referenced_source.ignored_files") { throw Object.assign( new Error(`fatal: unable to read tree object for ${sensitivePath}, pid 4242`), { stderr: `fatal: unable to read tree object for ${sensitivePath}, pid 4242` }, ); } return await runLocalGit(input.localDir, [...input.args], { timeout: input.timeout, maxBuffer: input.maxBuffer, env: input.env, }); }); const resolution = await resolveReferencedSourceIgnore(repo); expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed }); } finally { setExpensiveWorkspaceGitExecutor(null); } }); it(`redacts a non-descendant toplevel embedding ${sensitivePath} to the fixed category`, async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-redact-nondescendant-")); cleanupDirs.push(rootDir); const localPath = path.join(rootDir, "referenced"); await mkdir(localPath, { recursive: true }); try { // A toplevel string with no relation to `localPath` — the resolver // must treat it as a non-descendant and fail closed with the fixed // category, never a message built from `sensitivePath` or `localPath`. setExpensiveWorkspaceGitExecutor(async (input) => { if (input.operation === "referenced_source.toplevel") { return { stdout: `${sensitivePath}\n`, stderr: "" }; } return { stdout: "", stderr: "" }; }); const resolution = await resolveReferencedSourceIgnore(localPath); expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.toplevelNotDescendant }); } finally { setExpensiveWorkspaceGitExecutor(null); } }); } it("fails closed with a fixed category when the parsed ignored-entry count exceeds the bound", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-bound-count-")); cleanupDirs.push(rootDir); const repo = path.join(rootDir, "repo"); await initGitRepo(repo); const overLimitCount = 10_001; const syntheticIgnored = `${Array.from({ length: overLimitCount }, (_, index) => `!! entry-${index}`).join("\0")}\0`; try { setExpensiveWorkspaceGitExecutor(async (input) => { if (input.operation === "referenced_source.ignored_files") { return { stdout: syntheticIgnored, stderr: "" }; } return await runLocalGit(input.localDir, [...input.args], { timeout: input.timeout, maxBuffer: input.maxBuffer, env: input.env, }); }); const resolution = await resolveReferencedSourceIgnore(repo); expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded }); } finally { setExpensiveWorkspaceGitExecutor(null); } }); it("fails closed with a fixed category when the total UTF-8 byte size of ignored paths exceeds the bound", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-bound-bytes-")); cleanupDirs.push(rootDir); const repo = path.join(rootDir, "repo"); await initGitRepo(repo); // One entry alone exceeds the 2 MiB bound, well under the entry-count bound. const hugeEntry = "a".repeat(3 * 1024 * 1024); const syntheticIgnored = `!! ${hugeEntry}\0`; try { setExpensiveWorkspaceGitExecutor(async (input) => { if (input.operation === "referenced_source.ignored_files") { return { stdout: syntheticIgnored, stderr: "" }; } return await runLocalGit(input.localDir, [...input.args], { timeout: input.timeout, maxBuffer: input.maxBuffer, env: input.env, }); }); const resolution = await resolveReferencedSourceIgnore(repo); expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded }); } finally { setExpensiveWorkspaceGitExecutor(null); } }); it("stages no bytes for either a count-breach or a byte-breach project, and stages a healthy sibling", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-bound-staging-")); cleanupDirs.push(rootDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); const healthyDir = path.join(rootDir, "referenced-healthy"); const countBreachDir = path.join(rootDir, "referenced-count-breach"); const byteBreachDir = path.join(rootDir, "referenced-byte-breach"); await mkdir(localWorkspaceDir, { recursive: true }); await mkdir(healthyDir, { recursive: true }); await mkdir(countBreachDir, { recursive: true }); await mkdir(byteBreachDir, { recursive: true }); await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8"); await writeFile(path.join(healthyDir, "notes.md"), "healthy\n", "utf8"); await writeFile(path.join(countBreachDir, "should-never-ship.txt"), "must not stage\n", "utf8"); await writeFile(path.join(byteBreachDir, "should-never-ship.txt"), "must not stage\n", "utf8"); const countBreachReason = { kind: "failed" as const, reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded }; const byteBreachReason = { kind: "failed" as const, reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.limitExceeded }; const prepared = await prepareCommandManagedRuntime({ runner: makeInlineSpawnRunner(), spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, adapterKey: "test-adapter", workspaceLocalDir: localWorkspaceDir, additionalSources: [ { localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } }, { localPath: countBreachDir, projectId: "count-breach", ignoreResolution: countBreachReason }, { localPath: byteBreachDir, projectId: "byte-breach", ignoreResolution: byteBreachReason }, ], }); expect(Object.keys(prepared.additionalSourceDirs).sort()).toEqual(["healthy"]); expect(prepared.additionalSourceFailures.map((failure) => failure.projectId).sort()).toEqual([ "byte-breach", "count-breach", ]); const runtimeRootDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "test-adapter"); await expect(readFile(path.join(runtimeRootDir, "project-count-breach", "should-never-ship.txt"), "utf8")).rejects .toMatchObject({ code: "ENOENT" }); await expect(readFile(path.join(runtimeRootDir, "project-byte-breach", "should-never-ship.txt"), "utf8")).rejects .toMatchObject({ code: "ENOENT" }); }); describe("saturation retry", () => { afterEach(() => { vi.useRealTimers(); setExpensiveWorkspaceGitExecutor(null); }); function throwSaturated(): never { throw Object.assign( new Error("Changed files are temporarily unavailable because the Git scan queue is full"), { code: WORKSPACE_GIT_SCAN_SATURATED_CODE }, ); } // Every case here synthesizes BOTH scan operations at the executor seam // instead of spawning real `git` — the property under test is the // retry's own timing and attempt count, and a fake-timer-driven test // must not also depend on a real child process's independent, real-time // completion. `toplevel` need not exist on disk: `resolveReferencedSourceIgnore` // falls back to a plain string compare when `fs.realpath` fails, and the // fixture path is used unchanged on both sides of that compare. const repo = "/fixture/referenced-project"; it("retries a saturated scan up to two times and succeeds on the third attempt", async () => { let ignoredCallCount = 0; setExpensiveWorkspaceGitExecutor(async (input) => { if (input.operation === "referenced_source.toplevel") { return { stdout: `${repo}\n`, stderr: "" }; } ignoredCallCount += 1; if (ignoredCallCount <= 2) throwSaturated(); return { stdout: "", stderr: "" }; }); vi.useFakeTimers(); const resolutionPromise = resolveReferencedSourceIgnore(repo); // Bounded backoff: none before attempt 1, 1 s before attempt 2, 2 s // before attempt 3. await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(2_000); const resolution = await resolutionPromise; expect(resolution).toEqual({ kind: "git", ignoredPaths: [] }); expect(ignoredCallCount).toBe(3); }); it("fails closed after three saturated attempts, with no further Git invocation", async () => { let ignoredCallCount = 0; setExpensiveWorkspaceGitExecutor(async (input) => { if (input.operation === "referenced_source.toplevel") { return { stdout: `${repo}\n`, stderr: "" }; } ignoredCallCount += 1; throwSaturated(); }); vi.useFakeTimers(); const resolutionPromise = resolveReferencedSourceIgnore(repo); await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(2_000); const resolution = await resolutionPromise; expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed }); // Three total attempts (the first plus two retries) — no fourth, // unscheduled invocation past the retry budget. expect(ignoredCallCount).toBe(3); }); it("makes exactly one attempt and fails closed on a timeout, never retrying it", async () => { let ignoredCallCount = 0; setExpensiveWorkspaceGitExecutor(async (input) => { if (input.operation === "referenced_source.toplevel") { return { stdout: `${repo}\n`, stderr: "" }; } ignoredCallCount += 1; throw Object.assign(new Error("Workspace Git scan timed out after 8000ms"), { code: "workspace_git_scan_timeout", }); }); const resolution = await resolveReferencedSourceIgnore(repo); expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed }); expect(ignoredCallCount).toBe(1); }); it("makes exactly one attempt and fails closed on an output-limit breach, never retrying it", async () => { let ignoredCallCount = 0; setExpensiveWorkspaceGitExecutor(async (input) => { if (input.operation === "referenced_source.toplevel") { return { stdout: `${repo}\n`, stderr: "" }; } ignoredCallCount += 1; throw Object.assign(new Error("Workspace Git scan exceeded its output limit"), { code: "workspace_git_scan_output_limit", }); }); const resolution = await resolveReferencedSourceIgnore(repo); expect(resolution).toEqual({ kind: "failed", reason: REFERENCED_SOURCE_IGNORE_FAILURE_REASONS.scanFailed }); expect(ignoredCallCount).toBe(1); }); }); }); it("escapes glob metacharacters so a literal ignored path never over-excludes a sibling", async () => { // A bare tar `--exclude` pattern treats `*`/`?`/`[` as globs. Without // escaping, an ignored file named `secret[1].txt` would exclude the // unrelated sibling `secret1.txt` too (both match the glob `secret[1].txt`, // whose `[1]` is a one-character class matching the literal digit `1`). expect(escapeTarExcludeLiteral("secret[1].txt")).toBe("secret\\[1].txt"); expect(escapeTarExcludeLiteral("wildcard*name")).toBe("wildcard\\*name"); expect(escapeTarExcludeLiteral("question?mark")).toBe("question\\?mark"); const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-glob-")); cleanupDirs.push(rootDir); const referencedDir = path.join(rootDir, "referenced-project"); await initGitRepo(referencedDir); // The gitignore pattern itself escapes `[` and `]` (gitignore patterns are // globs too), so it ignores ONLY the literal file `secret[1].txt`. await writeFile(path.join(referencedDir, ".gitignore"), "secret\\[1\\].txt\n", "utf8"); await writeFile(path.join(referencedDir, "secret[1].txt"), "ignored\n", "utf8"); // A sibling that would ALSO match the UNESCAPED tar exclude glob // `secret[1].txt` (its `[1]` is a one-character class matching `1`), if the // staging path failed to escape the ignored entry before passing it to tar. await writeFile(path.join(referencedDir, "secret1.txt"), "must stay\n", "utf8"); const ignoreResolution = await resolveReferencedSourceIgnore(referencedDir); expect(ignoreResolution).toEqual({ kind: "git", ignoredPaths: ["secret[1].txt"] }); const prepared = await stageOneReferencedProject(referencedDir, ignoreResolution); const stagedDir = prepared.additionalSourceDirs["proj"]!; await expect(readFile(path.join(stagedDir, "secret[1].txt"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(path.join(stagedDir, "secret1.txt"), "utf8")).resolves.toBe("must stay\n"); }); it("never ships a Git-ignored secret in a referenced project's staged tree", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-secret-")); cleanupDirs.push(rootDir); const referencedDir = path.join(rootDir, "referenced-project"); await initGitRepo(referencedDir); await writeFile(path.join(referencedDir, ".gitignore"), "secret.env\n", "utf8"); await writeFile(path.join(referencedDir, "secret.env"), "TOKEN=abc\n", "utf8"); await writeFile(path.join(referencedDir, "tracked.md"), "kept\n", "utf8"); const ignoreResolution = await resolveReferencedSourceIgnore(referencedDir); expect(ignoreResolution).toEqual({ kind: "git", ignoredPaths: ["secret.env"] }); const prepared = await stageOneReferencedProject(referencedDir, ignoreResolution); const stagedDir = prepared.additionalSourceDirs["proj"]!; await expect(readFile(path.join(stagedDir, "secret.env"), "utf8")).rejects.toMatchObject({ code: "ENOENT" }); await expect(readFile(path.join(stagedDir, "tracked.md"), "utf8")).resolves.toBe("kept\n"); }); it("stages no bytes for a project whose ignore resolution failed, and stages the rest", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-ignore-failed-skip-")); cleanupDirs.push(rootDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); const healthyDir = path.join(rootDir, "referenced-healthy"); const failedDir = path.join(rootDir, "referenced-failed"); await mkdir(localWorkspaceDir, { recursive: true }); await mkdir(healthyDir, { recursive: true }); await mkdir(failedDir, { recursive: true }); await writeFile(path.join(localWorkspaceDir, "README.md"), "anchor\n", "utf8"); await writeFile(path.join(healthyDir, "notes.md"), "healthy\n", "utf8"); await writeFile(path.join(failedDir, "should-never-ship.txt"), "must not stage\n", "utf8"); const prepared = await prepareCommandManagedRuntime({ runner: makeInlineSpawnRunner(), spec: { remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000 }, adapterKey: "test-adapter", workspaceLocalDir: localWorkspaceDir, additionalSources: [ { localPath: healthyDir, projectId: "healthy", ignoreResolution: { kind: "other" } }, { localPath: failedDir, projectId: "failed", ignoreResolution: { kind: "failed", reason: "boom: git status timed out" } }, ], }); // The failed project is not staged at all (fail closed) — no bytes reach the // sandbox for it — and is recorded as a first-class failure. The healthy // project stages normally. expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["healthy"]); expect(prepared.additionalSourceFailures.map((failure) => failure.projectId)).toEqual(["failed"]); expect(prepared.additionalSourceFailures[0]!.error).toContain("boom: git status timed out"); const runtimeRootDir = path.posix.join(remoteWorkspaceDir, ".paperclip-runtime", "test-adapter"); await expect(readFile(path.join(runtimeRootDir, "project-failed", "should-never-ship.txt"), "utf8")).rejects .toMatchObject({ code: "ENOENT" }); }); it("builds the workspace tarball inside one host pack span for a usual workspace sync", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pack-span-")); cleanupDirs.push(rootDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); await mkdir(localWorkspaceDir, { recursive: true }); await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace body\n", "utf8"); // Record every span name the runner opens and run the wrapped work, so the // test proves the host opens a span around each host-side staging sub-step // for the usual (plain) workspace sync: the git enumeration, the baseline // content-hash walk, and the tarball build, in that order. const openedSpans: string[] = []; const runtimeSpan: RuntimeSpanRunner = async (name, work) => { openedSpans.push(name); return await work(); }; const prepared = await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-pack", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client: makeFilesystemClient(), workspaceLocalDir: localWorkspaceDir, runtimeSpan, }); // The workspace stage task opens its own `stage.workspace` span, and the // host tarball build opens the `pack` span inside it. The two pre-task // sub-steps stay ahead of the task. expect(openedSpans).toEqual(["snapshot.git", "snapshot.baseline", "stage.workspace", "pack"]); // The tarball build still lands the workspace inside the span, so the wrap // changes no staging behavior. await expect(readFile(path.join(remoteWorkspaceDir, "README.md"), "utf8")).resolves.toBe("workspace body\n"); expect(prepared.workspaceRemoteDir).toBe(remoteWorkspaceDir); }); it("nests the host pack span under the stage.workspace task span", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-pack-nest-")); cleanupDirs.push(rootDir); const localWorkspaceDir = path.join(rootDir, "local-workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); await mkdir(localWorkspaceDir, { recursive: true }); await writeFile(path.join(localWorkspaceDir, "README.md"), "workspace body\n", "utf8"); const { traceContext, spans } = createRecordingTraceContext(); // The root span stands in for `sandbox.startup`. Its child context is the // step span's parent, exactly as the executor wires it. const rootHandle = traceContext.tracer.startSpan("sandbox.startup", undefined, undefined); const rootContext = traceContext.contextWithSpan(rootHandle); // The stage runner parents each span to the ACTIVE startup step, so the // `pack` span nests under `stage.sync`. This is the exact runner the // executor threads into the staging seam. const stageRuntimeSpan = createRuntimeSpanRunner( traceContext, () => getActiveStepContext()?.parentContext, ); // A deterministic monotonic clock, so the step timing stays test-stable. let clock = 0; const now = () => (clock += 1000); await measureStartupStep( {}, now, "stage.sync", async () => { await prepareSandboxManagedRuntime({ spec: { transport: "sandbox", provider: "test", sandboxId: "sandbox-nest", remoteCwd: remoteWorkspaceDir, timeoutMs: 30_000, apiKey: null, }, adapterKey: "test-adapter", client: makeFilesystemClient(), workspaceLocalDir: localWorkspaceDir, runtimeSpan: stageRuntimeSpan, }); }, { tracer: traceContext.tracer, parentContext: rootContext, contextWithSpan: (span) => traceContext.contextWithSpan(span), }, ); const stageSpan = spans.find((span) => span.name === "stage.sync"); const workspaceSpan = spans.find((span) => span.name === "stage.workspace"); const packSpan = spans.find((span) => span.name === "pack"); expect(stageSpan).toBeDefined(); expect(workspaceSpan).toBeDefined(); expect(packSpan).toBeDefined(); expect(packSpan!.ended).toBe(true); // The workspace stage task opens its own `stage.workspace` span under // `stage.sync`, and the `pack` span nests under `stage.workspace`. expect(workspaceSpan!.parentName).toBe("stage.sync"); expect(packSpan!.parentName).toBe("stage.workspace"); // The two pre-`pack` staging sub-steps nest under `stage.sync` the same way, // so the previously hidden gap at the head of the step is now attributed. for (const name of ["snapshot.git", "snapshot.baseline"]) { const span = spans.find((candidate) => candidate.name === name); expect(span, name).toBeDefined(); expect(span!.ended).toBe(true); expect(span!.parentName).toBe("stage.sync"); } }); }); // A deferred promise a test resolves or rejects by hand. interface Deferred { promise: Promise; resolve: (value: T) => void; reject: (reason: unknown) => void; } function defer(): Deferred { let resolve!: (value: T) => void; let reject!: (reason: unknown) => void; const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } function settleTick(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } // A span recorder that captures each opened span name and the set of spans that // are open right now. `opened` records every span in open order. `openNow` holds // the names of the spans that started but did not end yet, so a test proves two // concurrent tasks keep their spans open at the same time. function makeSpanRecorder(): { runtimeSpan: RuntimeSpanRunner; opened: string[]; openNow: Set; } { const opened: string[] = []; const openNow = new Set(); const runtimeSpan: RuntimeSpanRunner = async (name, work) => { opened.push(name); openNow.add(name); try { return await work(); } finally { openNow.delete(name); } }; return { runtimeSpan, opened, openNow }; } // A controlled `syncIn` client. It labels each inbound operation, records the // start and settle order, and lets a test hold one upload open, release it, or // make it fail. One operation rides each `syncIn` call, so one call maps to one // label. This exercises the inbound coordinator's schedule, bound, failure // semantics, and startup barrier with deferred-promise fakes. interface SyncControl { started: string[]; settled: string[]; waitForStart(label: string): Promise; hold(label: string): void; release(label: string): void; failWith(label: string, error: Error): void; } function labelOfOperation(operation: SandboxSyncOperation): string { const bases = operation.files.map((mapping) => path.posix.basename(mapping.targetPath)); if (bases.some((base) => base === "workspace-upload.tar" || base === "git-workspace-upload.tar")) { return "workspace"; } const assetBase = bases.find((base) => base.endsWith("-upload.tar")); if (assetBase) { return assetBase.slice(0, -"-upload.tar".length); } const projectBase = bases.find((base) => base.startsWith("project-")); if (projectBase) { return projectBase; } return bases[0] ?? operation.operationId; } function makeControlledSyncClient(options: { concurrent: boolean }): { client: SandboxManagedRuntimeClient; control: SyncControl; } { const started: string[] = []; const settled: string[] = []; const gates = new Map>(); const failures = new Map(); const startWaiters = new Map void>>(); const control: SyncControl = { started, settled, waitForStart(label) { if (started.includes(label)) { return Promise.resolve(); } return new Promise((resolve) => { const waiters = startWaiters.get(label) ?? []; waiters.push(resolve); startWaiters.set(label, waiters); }); }, hold(label) { if (!gates.has(label)) { gates.set(label, defer()); } }, release(label) { gates.get(label)?.resolve(); }, failWith(label, error) { failures.set(label, error); gates.get(label)?.resolve(); }, }; const noop = async (): Promise => {}; const client: SandboxManagedRuntimeClient = { makeDir: noop, writeFile: noop, readFile: async () => Buffer.alloc(0), listFiles: async () => [], remove: noop, run: noop, allowConcurrentSyncOperations: options.concurrent, syncIn: async (operations) => { const operation = operations[0]!; const label = labelOfOperation(operation); started.push(label); const waiters = startWaiters.get(label) ?? []; startWaiters.delete(label); for (const waiter of waiters) { waiter(); } try { const gate = gates.get(label); if (gate) { await gate.promise; } const failure = failures.get(label); if (failure) { throw failure; } return { operations: [{ operationId: operation.operationId, filesTransferred: operation.files.length, bytesTransferred: 0, }], }; } finally { settled.push(label); } }, }; return { client, control }; } describe("sandbox managed runtime inbound coordinator", () => { 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); } }); function makeSpec(remoteCwd: string) { return { transport: "sandbox" as const, provider: "test", sandboxId: "sandbox-1", remoteCwd, timeoutMs: 30_000, apiKey: null, }; } // Create a temp root with one workspace directory and any named asset/project // directories. Each directory carries one file, so the host tar step has bytes. async function makeInboundDirs(names: string[]): Promise<{ rootDir: string; workspaceDir: string; dirOf: (name: string) => string; }> { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-inbound-coordinator-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); await mkdir(workspaceDir, { recursive: true }); await writeFile(path.join(workspaceDir, "file.txt"), "workspace\n", "utf8"); const dirs = new Map(); for (const name of names) { const dir = path.join(rootDir, name); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, "file.txt"), `${name}\n`, "utf8"); dirs.set(name, dir); } return { rootDir, workspaceDir, dirOf: (name) => dirs.get(name)! }; } // Resolve true when the label started within the window, false on timeout. async function startedWithin(control: SyncControl, label: string, ms: number): Promise { return Promise.race([ control.waitForStart(label).then(() => true), settleTick(ms).then(() => false), ]); } it("with concurrency permitted, the home asset upload starts while the workspace upload is held open", async () => { const { workspaceDir, dirOf } = await makeInboundDirs(["home"]); const { client, control } = makeControlledSyncClient({ concurrent: true }); control.hold("workspace"); const prepared = prepareSandboxManagedRuntime({ spec: makeSpec("/remote/cwd"), adapterKey: "codex", client, workspaceLocalDir: workspaceDir, assets: [{ key: "home", localDir: dirOf("home") }], }); prepared.catch(() => undefined); // The workspace upload is open and held. The home asset upload still starts, // so the coordinator runs the two operations concurrently. await control.waitForStart("workspace"); expect(await startedWithin(control, "home", 4000)).toBe(true); expect(control.settled).not.toContain("workspace"); control.release("workspace"); await prepared; expect(control.settled).toContain("home"); }); it("with five operations and the bound of 4, the fifth operation does not start while four stay open", async () => { expect(SYNC_OPERATION_CONCURRENCY_LIMIT).toBe(4); const assetKeys = Array.from( { length: SYNC_OPERATION_CONCURRENCY_LIMIT + 1 }, (_unused, index) => `asset-${index}`, ); const { workspaceDir, dirOf } = await makeInboundDirs(assetKeys); const { client, control } = makeControlledSyncClient({ concurrent: true }); for (const key of assetKeys) { control.hold(key); } const prepared = prepareSandboxManagedRuntime({ spec: makeSpec("/remote/cwd"), adapterKey: "codex", client, syncWorkspace: false, workspaceLocalDir: workspaceDir, assets: assetKeys.map((key) => ({ key, localDir: dirOf(key) })), }); prepared.catch(() => undefined); const firstFour = assetKeys.slice(0, SYNC_OPERATION_CONCURRENCY_LIMIT); const fifth = assetKeys[SYNC_OPERATION_CONCURRENCY_LIMIT]!; for (const key of firstFour) { await control.waitForStart(key); } // The bound holds the fifth operation while four stay open. expect(await startedWithin(control, fifth, 300)).toBe(false); expect(control.started.slice().sort()).toEqual(firstFour.slice().sort()); // One release frees one slot, so the fifth operation starts. control.release(firstFour[0]!); expect(await startedWithin(control, fifth, 4000)).toBe(true); for (const key of assetKeys) { control.release(key); } await prepared; expect(control.settled.slice().sort()).toEqual(assetKeys.slice().sort()); }); it("holds one upload open after another upload fails, and returns only after both settle", async () => { const { workspaceDir, dirOf } = await makeInboundDirs(["asset-a", "asset-b"]); const { client, control } = makeControlledSyncClient({ concurrent: true }); control.hold("asset-b"); let coordinatorSettled = false; const prepared = prepareSandboxManagedRuntime({ spec: makeSpec("/remote/cwd"), adapterKey: "codex", client, syncWorkspace: false, workspaceLocalDir: workspaceDir, assets: [ { key: "asset-a", localDir: dirOf("asset-a") }, { key: "asset-b", localDir: dirOf("asset-b") }, ], }); const done = prepared.then( () => { coordinatorSettled = true; }, () => { coordinatorSettled = true; }, ); control.failWith("asset-a", new Error("asset-a-fail")); await control.waitForStart("asset-b"); await settleTick(100); // The first upload already failed. The second upload is still open, so the // coordinator must not return yet. expect(coordinatorSettled).toBe(false); expect(control.settled).toContain("asset-a"); expect(control.settled).not.toContain("asset-b"); control.release("asset-b"); await done; expect(coordinatorSettled).toBe(true); expect(control.settled).toEqual(expect.arrayContaining(["asset-a", "asset-b"])); }); it("records a referenced-project failure as nonfatal and finishes the other referenced-project uploads", async () => { const { workspaceDir, dirOf } = await makeInboundDirs(["good", "bad"]); const { client, control } = makeControlledSyncClient({ concurrent: true }); control.failWith("project-bad", new Error("bad-upload")); const prepared = await prepareSandboxManagedRuntime({ spec: makeSpec("/remote/cwd"), adapterKey: "codex", client, syncWorkspace: false, workspaceLocalDir: workspaceDir, additionalSources: [ { localPath: dirOf("good"), projectId: "good", ignoreResolution: { kind: "other" } }, { localPath: dirOf("bad"), projectId: "bad", ignoreResolution: { kind: "other" } }, ], }); // The healthy project synced; the failed project is a recorded, nonfatal // outcome, so the coordinator resolved. expect(Object.keys(prepared.additionalSourceDirs)).toEqual(["good"]); expect(prepared.additionalSourceFailures.map((failure) => failure.projectId)).toEqual(["bad"]); expect(prepared.additionalSourceFailures[0]!.error).toContain("bad-upload"); expect(control.settled).toContain("project-good"); }); it("raises an asset failure as fatal after the barrier", async () => { const { workspaceDir, dirOf } = await makeInboundDirs(["asset-good", "asset-bad"]); const { client, control } = makeControlledSyncClient({ concurrent: true }); control.failWith("asset-bad", new Error("asset-bad-fail")); await expect( prepareSandboxManagedRuntime({ spec: makeSpec("/remote/cwd"), adapterKey: "codex", client, syncWorkspace: false, workspaceLocalDir: workspaceDir, assets: [ { key: "asset-good", localDir: dirOf("asset-good") }, { key: "asset-bad", localDir: dirOf("asset-bad") }, ], }), ).rejects.toThrow("asset-bad-fail"); // The barrier still settles the healthy asset before the coordinator raises. expect(control.settled).toContain("asset-good"); }); it("raises the earlier required failure when the workspace and an asset both fail", async () => { const { workspaceDir, dirOf } = await makeInboundDirs(["asset-a"]); const { client, control } = makeControlledSyncClient({ concurrent: true }); control.failWith("workspace", new Error("workspace-fail")); control.failWith("asset-a", new Error("asset-a-fail")); // The workspace comes before the asset in stable operation order, so the // coordinator raises the workspace failure. await expect( prepareSandboxManagedRuntime({ spec: makeSpec("/remote/cwd"), adapterKey: "codex", client, workspaceLocalDir: workspaceDir, assets: [{ key: "asset-a", localDir: dirOf("asset-a") }], }), ).rejects.toThrow("workspace-fail"); }); it("with concurrency forbidden, keeps the serial schedule in the current order", async () => { const { workspaceDir, dirOf } = await makeInboundDirs(["asset-a"]); const { client, control } = makeControlledSyncClient({ concurrent: false }); control.hold("workspace"); const prepared = prepareSandboxManagedRuntime({ spec: makeSpec("/remote/cwd"), adapterKey: "codex", client, workspaceLocalDir: workspaceDir, assets: [{ key: "asset-a", localDir: dirOf("asset-a") }], }); prepared.catch(() => undefined); // The workspace runs first and is held open. Serial mode runs one operation // at a time, so the asset upload does not start until the workspace settles. await control.waitForStart("workspace"); expect(await startedWithin(control, "asset-a", 300)).toBe(false); expect(control.started).toEqual(["workspace"]); control.release("workspace"); await control.waitForStart("asset-a"); await prepared; // The serial order stays workspace first, then the asset. expect(control.started).toEqual(["workspace", "asset-a"]); }); it("opens one named span per inbound task: stage.workspace, stage.asset., stage.project.", async () => { const { workspaceDir, dirOf } = await makeInboundDirs(["home", "proj"]); const { client } = makeControlledSyncClient({ concurrent: false }); const { runtimeSpan, opened } = makeSpanRecorder(); await prepareSandboxManagedRuntime({ spec: makeSpec("/remote/cwd"), adapterKey: "codex", client, workspaceLocalDir: workspaceDir, assets: [{ key: "home", localDir: dirOf("home") }], additionalSources: [{ localPath: dirOf("proj"), projectId: "proj-1", ignoreResolution: { kind: "other" } }], runtimeSpan, }); // Each inbound task carries its own named span. The workspace task, the home // asset task, and the referenced-project task each open one. expect(opened).toContain("stage.workspace"); expect(opened).toContain("stage.asset.home"); expect(opened).toContain("stage.project.proj-1"); }); it("with concurrency permitted, the workspace and asset inbound task spans overlap in time", async () => { const { workspaceDir, dirOf } = await makeInboundDirs(["home"]); const { client, control } = makeControlledSyncClient({ concurrent: true }); const { runtimeSpan, openNow } = makeSpanRecorder(); control.hold("workspace"); control.hold("home"); const prepared = prepareSandboxManagedRuntime({ spec: makeSpec("/remote/cwd"), adapterKey: "codex", client, workspaceLocalDir: workspaceDir, assets: [{ key: "home", localDir: dirOf("home") }], runtimeSpan, }); prepared.catch(() => undefined); // Both uploads are held open at their transfer. Each task span opens before // its transfer and stays open while the transfer is held, so the two spans // are open at the same time. await control.waitForStart("workspace"); await control.waitForStart("home"); expect(openNow.has("stage.workspace")).toBe(true); expect(openNow.has("stage.asset.home")).toBe(true); control.release("workspace"); control.release("home"); await prepared; }); }); // A controlled outbound restore. It reuses the deferred-promise fakes. The // native `syncOut` copies the sandbox workspace back into the restore temp // directory, and a gate holds the workspace restore open. Each asset carries a // controlled `restore` callback the test can hold, release, or make fail. One // label rides the workspace restore and one label rides each asset restore, so // a test can watch the outbound coordinator schedule, bound, failure semantics, // and teardown barrier. interface OutboundControl { started: string[]; settled: string[]; restoreTempDirs: Map; waitForStart(label: string): Promise; hold(label: string): void; release(label: string): void; failWith(label: string, error: Error): void; } function makeOutboundControl(): { control: OutboundControl; gate: (label: string, run: () => Promise) => Promise; } { const started: string[] = []; const settled: string[] = []; const restoreTempDirs = new Map(); const gates = new Map>(); const failures = new Map(); const startWaiters = new Map void>>(); const control: OutboundControl = { started, settled, restoreTempDirs, waitForStart(label) { if (started.includes(label)) { return Promise.resolve(); } return new Promise((resolve) => { const waiters = startWaiters.get(label) ?? []; waiters.push(resolve); startWaiters.set(label, waiters); }); }, hold(label) { if (!gates.has(label)) { gates.set(label, defer()); } }, release(label) { gates.get(label)?.resolve(); }, failWith(label, error) { failures.set(label, error); gates.get(label)?.resolve(); }, }; // Record the task start, notify start waiters, wait for the gate, raise a set // failure, then run the task body and record the settle. A gate that a test // never holds resolves at once, so an unheld task runs straight through. async function gate(label: string, run: () => Promise): Promise { started.push(label); const waiters = startWaiters.get(label) ?? []; startWaiters.delete(label); for (const waiter of waiters) { waiter(); } try { const held = gates.get(label); if (held) { await held.promise; } const failure = failures.get(label); if (failure) { throw failure; } return await run(); } finally { settled.push(label); } } return { control, gate }; } // A native filesystem client whose `syncOut` copies the sandbox workspace back // through the gate. The inbound prepare step uses the base64-tar fallback // `syncIn`. The client opts into concurrency by the flag. function makeGatedOutboundClient( concurrent: boolean, gate: (label: string, run: () => Promise) => Promise, ): 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) => 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(); }, remove: async (remotePath) => { await rm(remotePath, { recursive: true, force: true }); }, run: async (command) => { await execFile("sh", ["-c", command], { maxBuffer: 32 * 1024 * 1024 }); }, allowConcurrentSyncOperations: concurrent, syncOut: async (operations) => gate("workspace", async () => { for (const operation of operations) { for (const mapping of operation.files) { if (mapping.kind === "directory") { await mirrorDirectory(mapping.sourcePath, mapping.targetPath); } 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, })), }; }), }; attachFallbackSyncIn(client); return client; } // Build one asset with a controlled `restore`. The restore records its own // temp directory, then runs through the gate. The temp directory proves that // two concurrent restore tasks keep separate scratch state. function makeControlledAsset( key: string, localDir: string, control: OutboundControl, gate: (label: string, run: () => Promise) => Promise, ): SandboxManagedRuntimeAsset { return { key, localDir, restore: async (ctx) => { control.restoreTempDirs.set(key, ctx.tempDir); await gate(key, async () => { if (ctx.tempDir) { await writeFile(path.join(ctx.tempDir, `scratch-${key}.txt`), key, "utf8"); } }); }, }; } describe("sandbox managed runtime outbound coordinator", () => { 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); } }); function makeSpec(remoteCwd: string) { return { transport: "sandbox" as const, provider: "test", sandboxId: "sandbox-1", remoteCwd, timeoutMs: 30_000, apiKey: null, }; } // Create a temp root with one workspace directory and any named asset // directories. Each directory carries one file, so the host tar step has // bytes and the merge has content. async function makeOutboundDirs(names: string[]): Promise<{ rootDir: string; workspaceDir: string; remoteWorkspaceDir: string; dirOf: (name: string) => string; }> { const rootDir = await mkdtemp(path.join(os.tmpdir(), "paperclip-outbound-coordinator-")); cleanupDirs.push(rootDir); const workspaceDir = path.join(rootDir, "workspace"); const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); await mkdir(workspaceDir, { recursive: true }); await writeFile(path.join(workspaceDir, "file.txt"), "workspace\n", "utf8"); const dirs = new Map(); for (const name of names) { const dir = path.join(rootDir, name); await mkdir(dir, { recursive: true }); await writeFile(path.join(dir, "file.txt"), `${name}\n`, "utf8"); dirs.set(name, dir); } return { rootDir, workspaceDir, remoteWorkspaceDir, dirOf: (name) => dirs.get(name)! }; } // Resolve true when the label started within the window, false on timeout. async function startedWithin(control: OutboundControl, label: string, ms: number): Promise { return Promise.race([ control.waitForStart(label).then(() => true), settleTick(ms).then(() => false), ]); } it("with concurrency permitted, an asset restore starts while the workspace restore is held open", async () => { const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["home"]); const { control, gate } = makeOutboundControl(); const client = makeGatedOutboundClient(true, gate); control.hold("workspace"); const prepared = await prepareSandboxManagedRuntime({ spec: makeSpec(remoteWorkspaceDir), adapterKey: "codex", client, workspaceLocalDir: workspaceDir, assets: [makeControlledAsset("home", dirOf("home"), control, gate)], }); const restore = prepared.restoreWorkspace(); restore.catch(() => undefined); // The workspace restore is open and held. The home asset restore still // starts, so the coordinator runs the two tasks concurrently. await control.waitForStart("workspace"); expect(await startedWithin(control, "home", 4000)).toBe(true); expect(control.settled).not.toContain("workspace"); control.release("workspace"); await restore; expect(control.settled).toContain("home"); expect(control.settled).toContain("workspace"); }); it("holds one restore open after another restore fails, and returns only after both settle", async () => { const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["asset-a", "asset-b"]); const { control, gate } = makeOutboundControl(); const client = makeGatedOutboundClient(true, gate); const prepared = await prepareSandboxManagedRuntime({ spec: makeSpec(remoteWorkspaceDir), adapterKey: "codex", client, syncWorkspace: false, workspaceLocalDir: workspaceDir, assets: [ makeControlledAsset("asset-a", dirOf("asset-a"), control, gate), makeControlledAsset("asset-b", dirOf("asset-b"), control, gate), ], }); control.hold("asset-b"); control.failWith("asset-a", new Error("asset-a-fail")); let coordinatorSettled = false; const done = prepared.restoreWorkspace().then( () => { coordinatorSettled = true; }, () => { coordinatorSettled = true; }, ); await control.waitForStart("asset-b"); await settleTick(100); // The first restore already failed. The second restore is still open, so // the coordinator must not return yet. expect(coordinatorSettled).toBe(false); expect(control.settled).toContain("asset-a"); expect(control.settled).not.toContain("asset-b"); control.release("asset-b"); await done; expect(coordinatorSettled).toBe(true); expect(control.settled).toEqual(expect.arrayContaining(["asset-a", "asset-b"])); }); it("with concurrency forbidden, keeps the serial restore schedule in the current order", async () => { const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["asset-a"]); const { control, gate } = makeOutboundControl(); const client = makeGatedOutboundClient(false, gate); control.hold("workspace"); const prepared = await prepareSandboxManagedRuntime({ spec: makeSpec(remoteWorkspaceDir), adapterKey: "codex", client, workspaceLocalDir: workspaceDir, assets: [makeControlledAsset("asset-a", dirOf("asset-a"), control, gate)], }); const restore = prepared.restoreWorkspace(); restore.catch(() => undefined); // The workspace restore runs first and is held open. Serial mode runs one // task at a time, so the asset restore does not start until the workspace // restore settles. await control.waitForStart("workspace"); expect(await startedWithin(control, "asset-a", 300)).toBe(false); expect(control.started).toEqual(["workspace"]); control.release("workspace"); await control.waitForStart("asset-a"); await restore; // The serial order stays the workspace restore first, then the asset. expect(control.started).toEqual(["workspace", "asset-a"]); }); it("with concurrency permitted, two concurrent restore tasks use separate temporary state", async () => { const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["asset-a", "asset-b"]); const { control, gate } = makeOutboundControl(); const client = makeGatedOutboundClient(true, gate); const prepared = await prepareSandboxManagedRuntime({ spec: makeSpec(remoteWorkspaceDir), adapterKey: "codex", client, syncWorkspace: false, workspaceLocalDir: workspaceDir, assets: [ makeControlledAsset("asset-a", dirOf("asset-a"), control, gate), makeControlledAsset("asset-b", dirOf("asset-b"), control, gate), ], }); control.hold("asset-a"); control.hold("asset-b"); const restore = prepared.restoreWorkspace(); // Both restore tasks start together under the bound. Each task carries its // own restore temp directory, so the two directories differ. await control.waitForStart("asset-a"); await control.waitForStart("asset-b"); const tempA = control.restoreTempDirs.get("asset-a"); const tempB = control.restoreTempDirs.get("asset-b"); expect(tempA).toBeTruthy(); expect(tempB).toBeTruthy(); expect(tempA).not.toBe(tempB); expect(tempA!).toContain("paperclip-sandbox-restore-"); expect(tempB!).toContain("paperclip-sandbox-restore-"); control.release("asset-a"); control.release("asset-b"); await restore; expect(control.settled).toEqual(expect.arrayContaining(["asset-a", "asset-b"])); }); it("opens one named span per outbound restore task: restore.workspace, restore.asset.", async () => { const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["home"]); const { control, gate } = makeOutboundControl(); const client = makeGatedOutboundClient(true, gate); const { runtimeSpan, opened } = makeSpanRecorder(); const prepared = await prepareSandboxManagedRuntime({ spec: makeSpec(remoteWorkspaceDir), adapterKey: "codex", client, workspaceLocalDir: workspaceDir, assets: [makeControlledAsset("home", dirOf("home"), control, gate)], runtimeSpan, }); await prepared.restoreWorkspace(); // Each outbound restore task carries its own named span. The workspace // restore task and the home asset restore task each open one. expect(opened).toContain("restore.workspace"); expect(opened).toContain("restore.asset.home"); }); it("with concurrency permitted, the workspace and asset restore task spans overlap in time", async () => { const { workspaceDir, remoteWorkspaceDir, dirOf } = await makeOutboundDirs(["home"]); const { control, gate } = makeOutboundControl(); const client = makeGatedOutboundClient(true, gate); const { runtimeSpan, openNow } = makeSpanRecorder(); const prepared = await prepareSandboxManagedRuntime({ spec: makeSpec(remoteWorkspaceDir), adapterKey: "codex", client, workspaceLocalDir: workspaceDir, assets: [makeControlledAsset("home", dirOf("home"), control, gate)], runtimeSpan, }); control.hold("workspace"); control.hold("home"); const restore = prepared.restoreWorkspace(); restore.catch(() => undefined); // Both restore tasks are held open. Each restore task span opens before its // work and stays open while the work is held, so the two spans are open at // the same time. await control.waitForStart("workspace"); await control.waitForStart("home"); expect(openNow.has("restore.workspace")).toBe(true); expect(openNow.has("restore.asset.home")).toBe(true); control.release("workspace"); control.release("home"); 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 { 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) => { const resultOperations: SandboxSyncResult["operations"] = []; for (const operation of operations) { capture.syncOutOperations.push(operation); // Report a real `bytesTransferred`, from a post-copy byte count, so a // test can assert on the emitted progress line — the same way a real // provider reports the bytes it actually moved. let bytesTransferred = 0; for (const mapping of operation.files) { if (mapping.kind === "directory") { await copyDirectoryWithExclude(mapping.sourcePath, mapping.targetPath, mapping.exclude); bytesTransferred += await directoryByteSize(mapping.targetPath); } else { await mkdir(path.dirname(mapping.targetPath), { recursive: true }); const bytes = await readFile(mapping.sourcePath); await writeFile(mapping.targetPath, bytes); bytesTransferred += bytes.byteLength; } } resultOperations.push({ operationId: operation.operationId, filesTransferred: operation.files.length, bytesTransferred, }); } return { operations: resultOperations }; }; } 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 { 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(""); }); it("reports the real transferred bytes for the native git-history export and workspace restore", async () => { const capture: TransportCapture = { syncOutOperations: [], readFilePaths: [] }; const { localWorkspaceDir, remoteWorkspaceDir } = await setupGitBackedWorkspace("paperclip-restore-bytes-"); const client = makeTransportClient(true, capture); const prepared = await prepareSandboxManagedRuntime({ spec: gitSpec(remoteWorkspaceDir), adapterKey: "test-adapter", client, workspaceLocalDir: localWorkspaceDir, }); // Add a sizeable, incompressible tracked file in the sandbox, so both the // packed git-history bundle and the restored working tree carry real, // non-trivial bytes well above the 0.1 MB rounding step. Random bytes, // not a repeated byte, so git's own pack compression cannot shrink the // bundle back down to a trivial size. await writeFile(path.join(remoteWorkspaceDir, "large.bin"), randomBytes(300 * 1024)); await commitInSandbox(remoteWorkspaceDir); const lines: string[] = []; await prepared.restoreWorkspace((line) => { lines.push(line); }); const exportLines = lines.filter((line) => line.includes("Exporting git history from environment")); const restoreLines = lines.filter((line) => line.includes("Restoring workspace from environment")); // Both terminal lines carry the real transferred byte total, not the // "0.0 MB" a discarded native `syncOut` result would leave behind. expect(exportLines.length).toBeGreaterThan(0); expect(exportLines.some((line) => /\(\d+\.\d\/\d+\.\d MB\)/.test(line))).toBe(true); expect(exportLines.some((line) => line.includes("(0.0/0.0 MB)"))).toBe(false); expect(restoreLines.length).toBeGreaterThan(0); expect(restoreLines.some((line) => /\(\d+\.\d\/\d+\.\d MB\)/.test(line))).toBe(true); expect(restoreLines.some((line) => line.includes("(0.0/0.0 MB)"))).toBe(false); }); });