diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts index fea9df5888..63cabb0932 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.test.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.test.ts @@ -8,6 +8,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { resetLocalGitIndexToHead } from "./git-workspace-sync.js"; import { + assertSyncOperationsConfined, mirrorDirectory, prepareSandboxManagedRuntime, type SandboxManagedRuntimeClient, @@ -443,9 +444,13 @@ describe("sandbox managed runtime", () => { "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 === "git_sync" && - /^Syncing git history to sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/.test(status.message) + status.phase === "config_sync" && + /^Syncing workspace to sandbox: 100% \(\d+\.\d\/\d+\.\d MB\)$/.test(status.message) ))).toBe(true); expect(runtimeStatuses.some((status) => ( status.phase === "export" && @@ -1342,21 +1347,18 @@ describe("sandbox managed runtime", () => { expect(directWrites).toEqual([]); expect(directRuns).toEqual([]); - // Two operations: git-workspace then workspace overlay. Each uploads a single - // tar as a `file` mapping and carries its extract as an ordered post-command. - expect(captured.length).toBeGreaterThanOrEqual(2); - const gitOp = captured.find((op) => - op.files.some((mapping) => mapping.targetPath.endsWith("git-workspace-upload.tar")), - ); - const workspaceOp = captured.find((op) => - op.files.some((mapping) => mapping.targetPath.endsWith("workspace-upload.tar")), - ); - expect(gitOp).toBeDefined(); - expect(workspaceOp).toBeDefined(); - expect(gitOp!.files.every((mapping) => mapping.kind === "file")).toBe(true); - // The git operation's post-upload command preserves `.paperclip-runtime` while - // replacing the rest of the tree (wipe-except-preserved), then untars. - const gitCommand = gitOp!.postUploadCommands![0].command; + // 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"); @@ -1368,16 +1370,168 @@ describe("sandbox managed runtime", () => { expect(prepared.workspaceRemoteDir).toBe(remoteWorkspaceDir); }); - // Regression lock: a representative `codex_local` start stages its inbound - // bytes — git history, workspace overlay, and the managed Codex `home` asset - // (auth.json merge) — as EXACTLY ONE `syncIn` operation each. Every inbound step - // is routed through `client.syncIn` (one native `uploadFiles` round-trip per - // operation, with the extract/merge carried as provider-executed - // `postUploadCommands`), with no separate custom-provision diversion. Assert - // the collapsed round-trip count so a future change that re-inlines a - // `writeFile`+`run` sequence — or fans one staging step across multiple - // operations — fails loudly here instead of silently regressing the start path. - it("collapses a representative codex_local start to one syncIn round-trip per inbound staging step", async () => { + 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"); @@ -1385,7 +1539,7 @@ describe("sandbox managed runtime", () => { const remoteWorkspaceDir = path.join(rootDir, "remote-workspace"); const homeDir = path.join(rootDir, "codex-home"); - // Git-backed workspace → git history + workspace overlay are two staging steps. + // 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"]); @@ -1458,30 +1612,31 @@ describe("sandbox managed runtime", () => { expect(directWrites).toEqual([]); expect(directRuns).toEqual([]); - // The collapsed count: exactly three inbound round-trips — git, workspace, home. - expect(captured).toHaveLength(3); - const gitOp = captured.find((op) => - op.files.some((mapping) => mapping.targetPath.endsWith("git-workspace-upload.tar")), - ); - const workspaceOp = captured.find((op) => - op.files.some((mapping) => mapping.targetPath.endsWith("workspace-upload.tar")), - ); - const homeOp = captured.find((op) => - op.files.some((mapping) => mapping.targetPath.endsWith("home-upload.tar")), - ); - expect(gitOp).toBeDefined(); + // 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 single native uploadFiles (all `file` mappings) whose + // 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 "3 operations" is 3 real round-trips. - expect(new Set(captured.map((op) => op.operationId)).size).toBe(3); + // 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")) diff --git a/packages/adapter-utils/src/sandbox-managed-runtime.ts b/packages/adapter-utils/src/sandbox-managed-runtime.ts index f41cd2d531..ae6d433c75 100644 --- a/packages/adapter-utils/src/sandbox-managed-runtime.ts +++ b/packages/adapter-utils/src/sandbox-managed-runtime.ts @@ -785,64 +785,54 @@ export async function prepareSandboxManagedRuntime(input: { await upload.finish(params.progressBytes, params.progressBytes); }; - // Upload a host tarball as a single `file` mapping with the extract/wipe/merge - // steps as ordered post-upload commands. A thin wrapper over - // `stageConfinedSyncIn` for the workspace/git anchor and asset paths. - const stageTarball = async (input2: { - tarPath: string; - remoteTar: string; - postUploadCommands: SandboxPostUploadCommand[]; - progressLabel: string; - statusPhase: RuntimeStatusPhase; - }): Promise => { - const tarSize = (await fs.stat(input2.tarPath)).size; - await stageConfinedSyncIn({ - files: [{ sourcePath: input2.tarPath, targetPath: input2.remoteTar, kind: "file" }], - postUploadCommands: input2.postUploadCommands, - sourceRoots: [tempDir], - targetRoots: [runtimeRootDir], - progressLabel: input2.progressLabel, - statusPhase: input2.statusPhase, - progressBytes: tarSize, - }); - }; - - if (syncWorkspace && gitSnapshot) { - await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to sandbox"); - await withShallowGitWorkspaceClone({ - localDir: input.workspaceLocalDir, - snapshot: gitSnapshot, - }, async (cloneDir) => { - // git-workspace preserves `.paperclip-runtime` on the target and the - // workspace overlay merges on top rather than replacing — expressed as - // the operation's ordered post-upload commands, not a plain replace. - const gitTarPath = path.join(tempDir, "git-workspace.tar"); - await createTarballFromDirectory({ - localDir: cloneDir, - archivePath: gitTarPath, - exclude: [".paperclip-runtime"], - }); - const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar"); - await stageTarball({ - tarPath: gitTarPath, - remoteTar: remoteGitTar, - postUploadCommands: [{ - command: buildWorkspaceTarExtractCommand({ - workspaceRemoteDir, - remoteTar: remoteGitTar, - wipeExceptNames: [".paperclip-runtime"], - }), - }], - progressLabel: "git history", - statusPhase: "git_sync", - }); - }); - } - if (syncWorkspace) { + // A git-backed workspace and a plain workspace both stage through ONE + // confined `syncIn` operation. A git-backed workspace carries TWO host tars — + // the git-history clone and the working-tree overlay — as two `file` mappings + // on the SAME operation, with their extract commands as ordered + // `postUploadCommands`. One operation shares one mkdir, one confine guard, one + // `uploadFiles`, and one rename exec, so the second `syncIn` round trip is + // removed. Build the whole merged file set and command list BEFORE the confine + // guard runs (inside `stageConfinedSyncIn`); never append a mapping after it. + const workspaceFiles: SandboxSyncFileMapping[] = []; + const workspacePostUploadCommands: SandboxPostUploadCommand[] = []; + let workspaceUploadBytes = 0; + + // 1. git-history tar (git-backed workspace only). Both tar targets live under + // `runtimeRootDir` (`.paperclip-runtime/`). The git extract + // wipes the target tree EXCEPT `.paperclip-runtime`, so the overlay tar, + // which sits under `.paperclip-runtime`, survives to run its own extract. + if (gitSnapshot) { + await emitRuntimeStatus(input.onRuntimeProgress, "git_sync", "Syncing git history to sandbox"); + const gitTarPath = path.join(tempDir, "git-workspace.tar"); + const remoteGitTar = path.posix.join(runtimeRootDir, "git-workspace-upload.tar"); + await withShallowGitWorkspaceClone({ + localDir: input.workspaceLocalDir, + snapshot: gitSnapshot, + }, async (cloneDir) => { + await createTarballFromDirectory({ + localDir: cloneDir, + archivePath: gitTarPath, + exclude: [".paperclip-runtime"], + }); + }); + workspaceFiles.push({ sourcePath: gitTarPath, targetPath: remoteGitTar, kind: "file" }); + workspacePostUploadCommands.push({ + command: buildWorkspaceTarExtractCommand({ + workspaceRemoteDir, + remoteTar: remoteGitTar, + wipeExceptNames: [".paperclip-runtime"], + }), + }); + workspaceUploadBytes += (await fs.stat(gitTarPath)).size; + } + + // 2. workspace-overlay tar. A git-backed overlay merges on top of the just + // extracted git tree (no wipe); a plain workspace wipes every child except + // the preserved names first. The extract runs AFTER the git extract. + await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox"); const workspaceTarPath = path.join(tempDir, "workspace.tar"); const workspaceArchiveDir = gitSnapshot ? path.join(tempDir, "workspace-overlay") : input.workspaceLocalDir; - await emitRuntimeStatus(input.onRuntimeProgress, "config_sync", "Syncing workspace to sandbox"); if (gitSnapshot) { await copySelectedWorkspaceEntries({ sourceDir: input.workspaceLocalDir, @@ -857,15 +847,15 @@ export async function prepareSandboxManagedRuntime(input: { exclude: gitSnapshot ? undefined : workspaceArchiveExclude, }); const remoteWorkspaceTar = path.posix.join(runtimeRootDir, "workspace-upload.tar"); - // git overlay merges on top of the just-extracted git tree (no wipe); - // non-git workspace wipes every child except the preserved names first. - const workspacePostUploadCommands: SandboxPostUploadCommand[] = [{ + workspaceFiles.push({ sourcePath: workspaceTarPath, targetPath: remoteWorkspaceTar, kind: "file" }); + workspacePostUploadCommands.push({ command: buildWorkspaceTarExtractCommand({ workspaceRemoteDir, remoteTar: remoteWorkspaceTar, wipeExceptNames: gitSnapshot ? null : [...preservedNames], }), - }]; + }); + // 3. Optional remove-deleted-paths command runs LAST, after both extracts. if (gitSnapshot && gitSnapshot.deletedPaths.length > 0) { workspacePostUploadCommands.push({ command: buildRemoveDeletedPathsCommand({ @@ -874,12 +864,19 @@ export async function prepareSandboxManagedRuntime(input: { }), }); } - await stageTarball({ - tarPath: workspaceTarPath, - remoteTar: remoteWorkspaceTar, + workspaceUploadBytes += (await fs.stat(workspaceTarPath)).size; + + // One confined `syncIn` for the whole merged workspace file set. The confine + // guard covers every mapping BEFORE any bytes upload (fail-closed): a source + // or target escape in EITHER tar mapping stops the upload of both. + await stageConfinedSyncIn({ + files: workspaceFiles, postUploadCommands: workspacePostUploadCommands, + sourceRoots: [tempDir], + targetRoots: [runtimeRootDir], progressLabel: "workspace", statusPhase: "config_sync", + progressBytes: workspaceUploadBytes, }); } diff --git a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts index 0a36af08d2..df19124bfc 100644 --- a/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts +++ b/packages/plugins/sandbox-providers/daytona/src/plugin.test.ts @@ -2956,6 +2956,159 @@ describe("daytona native file-sync hooks", () => { ).toBe(false); }); + // ------------------------------------------------------------------------- + // Merged git-workspace operation. A git-backed workspace stage-sync rides ONE + // operation whose `files` carry the git-history tar and the workspace-overlay + // tar, with the two extract commands as ordered `postUploadCommands`. The + // operation shares one mkdir, one confine guard, one `uploadFiles`, and one + // rename exec. + // ------------------------------------------------------------------------- + + it("stages a merged git-workspace operation as one uploadFiles batch and one rename exec, both extracts in order", async () => { + const hostDir = await makeHostDir(); + const gitTar = path.join(hostDir, "git-workspace.tar"); + const overlayTar = path.join(hostDir, "workspace.tar"); + await fs.writeFile(gitTar, "git-bytes"); + await fs.writeFile(overlayTar, "overlay-bytes"); + const runtimeDir = `${REMOTE_DIR}/.paperclip-runtime/adapter`; + + const sandbox = createMockSandbox(); + mockGet.mockResolvedValue(sandbox); + + const gitExtract = "git-history-extract"; + const overlayExtract = "workspace-overlay-extract"; + const result = await plugin.definition.onEnvironmentSyncIn?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: "merged-workspace", + files: [ + { sourcePath: gitTar, targetPath: `${runtimeDir}/git-workspace-upload.tar`, kind: "file" }, + { sourcePath: overlayTar, targetPath: `${runtimeDir}/workspace-upload.tar`, kind: "file" }, + ], + postUploadCommands: [{ command: gitExtract }, { command: overlayExtract }], + }, + ], + }); + + // One bulk upload carries BOTH tars; one rename exec promotes both temps. + expect(sandbox.fs.uploadFiles).toHaveBeenCalledTimes(1); + const [uploads] = sandbox.fs.uploadFiles.mock.calls[0] as [Array<{ source: string; destination: string }>]; + expect(uploads).toHaveLength(2); + const mvCalls = sandbox.process.executeCommand.mock.calls.filter(([cmd]: [string]) => + String(cmd).includes("mv -f"), + ); + expect(mvCalls).toHaveLength(1); + expect(String(mvCalls[0][0]).match(/mv -f /g)).toHaveLength(2); + + // Both extract commands ran, in array order, AFTER the upload (git first). + const orderOf = (cmd: string) => { + const idx = sandbox.process.executeCommand.mock.calls.findIndex(([c]: [string]) => c === cmd); + return sandbox.process.executeCommand.mock.invocationCallOrder[idx]; + }; + expect(orderOf(gitExtract)).toBeLessThan(orderOf(overlayExtract)); + expect(sandbox.fs.uploadFiles.mock.invocationCallOrder[0]).toBeLessThan(orderOf(gitExtract)); + + expect(result).toEqual({ + operations: [{ + operationId: "merged-workspace", + filesTransferred: 2, + bytesTransferred: "git-bytes".length + "overlay-bytes".length, + }], + }); + }); + + it("rejects a merged operation when either tar mapping target escapes the remote dir, before uploading", async () => { + const hostDir = await makeHostDir(); + const gitTar = path.join(hostDir, "git-workspace.tar"); + const overlayTar = path.join(hostDir, "workspace.tar"); + await fs.writeFile(gitTar, "git-bytes"); + await fs.writeFile(overlayTar, "overlay-bytes"); + const runtimeDir = `${REMOTE_DIR}/.paperclip-runtime/adapter`; + + const sandbox = createMockSandbox(); + mockGet.mockResolvedValue(sandbox); + + await expect( + plugin.definition.onEnvironmentSyncIn?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: "merged-escape", + files: [ + { sourcePath: gitTar, targetPath: `${runtimeDir}/git-workspace-upload.tar`, kind: "file" }, + // The overlay mapping target escapes the workspace remote dir. + { sourcePath: overlayTar, targetPath: `${REMOTE_DIR}/../../etc/workspace-upload.tar`, kind: "file" }, + ], + postUploadCommands: [{ command: "git-history-extract" }, { command: "workspace-overlay-extract" }], + }, + ], + }), + ).rejects.toThrow(/escapes the workspace remote dir|not a confined absolute path/); + + // Neither tar uploaded: the confine check on the escaping mapping trips first. + expect(sandbox.fs.uploadFiles).not.toHaveBeenCalled(); + }); + + it("stops the overlay and remove-deleted commands when the git extract fails (merged operation fail-fast)", async () => { + const hostDir = await makeHostDir(); + const gitTar = path.join(hostDir, "git-workspace.tar"); + const overlayTar = path.join(hostDir, "workspace.tar"); + await fs.writeFile(gitTar, "git-bytes"); + await fs.writeFile(overlayTar, "overlay-bytes"); + const runtimeDir = `${REMOTE_DIR}/.paperclip-runtime/adapter`; + + const sandbox = createMockSandbox(); + // The first (git-history) extract exits non-zero; every transfer/guard script + // stays green so the fail-fast loop is the only thing that can trip this test. + sandbox.process.executeCommand.mockImplementation(async (command: string) => { + if (command === "git-history-extract") { + return { exitCode: 5, result: "boom", artifacts: { stdout: "boom" } }; + } + return { exitCode: 0, result: "", artifacts: { stdout: "" } }; + }); + mockGet.mockResolvedValue(sandbox); + + await expect( + plugin.definition.onEnvironmentSyncIn?.({ + driverKey: "daytona", + companyId: "company-1", + environmentId: "env-1", + config: { timeoutMs: 300000, reuseLease: false }, + lease: syncLease(), + operations: [ + { + operationId: "merged-failfast", + files: [ + { sourcePath: gitTar, targetPath: `${runtimeDir}/git-workspace-upload.tar`, kind: "file" }, + { sourcePath: overlayTar, targetPath: `${runtimeDir}/workspace-upload.tar`, kind: "file" }, + ], + postUploadCommands: [ + { command: "git-history-extract" }, + { command: "workspace-overlay-extract" }, + { command: "remove-deleted-paths" }, + ], + }, + ], + }), + ).rejects.toThrow(/post-upload command failed \(exit 5\)/); + + // Fail-fast: the overlay extract and the remove-deleted command never ran. + const ran = (cmd: string) => + sandbox.process.executeCommand.mock.calls.some(([c]: [string]) => c === cmd); + expect(ran("git-history-extract")).toBe(true); + expect(ran("workspace-overlay-extract")).toBe(false); + expect(ran("remove-deleted-paths")).toBe(false); + }); + it("rejects a post-upload command cwd that escapes the remote dir lexically, before any exec (C2)", async () => { const hostDir = await makeHostDir(); const source = path.join(hostDir, "config.txt");