diff --git a/doc/sandbox-work-folders.md b/doc/sandbox-work-folders.md index 2e7bb88cdb..21ee8ad0cd 100644 --- a/doc/sandbox-work-folders.md +++ b/doc/sandbox-work-folders.md @@ -30,6 +30,13 @@ Credentials are not recorded in Playwright reports. These API checks supplement the required browser walkthrough, two real 180-second intervals, and recovery scenarios; passing them alone is not staging acceptance. +The separate three-turn Daytona runner qualification writes into +`PAPERCLIP_TASK_DIR` when the host supplies scoped folders. After each turn it +checks that run's final save and reads the exact bytes through the task file API. +It still checks the same sandbox, provider session, and ordered turn markers. +Runs without a scoped manifest retain the existing host-workspace assertions. +The test does not treat a missing host mirror as proof that a scoped file was lost. + The staging matrix covers legacy Codex and Claude with both CLI and ACP, legacy OpenCode and Pi, and native Codex, OpenCode, and ACPX Claude/Codex/Pi. Cursor, Gemini, Grok, and Kimi are excluded from this acceptance campaign by diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index 3d9abfbe36..2c79457474 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -104,6 +104,11 @@ describe("runner E2E catalog", () => { const initialPrompt = daytonaWarmContinuityTask.buildPrompt("nonce"); const followups = daytonaWarmContinuityTask.buildFollowupMessages?.("nonce") ?? []; + for (const prompt of [initialPrompt, ...followups]) { + expect(prompt).toContain( + 'if [ -n "${PAPERCLIP_TASK_DIR:-}" ]; then cd "$PAPERCLIP_TASK_DIR"; fi', + ); + } expect(initialPrompt).toContain('"kind":"request_confirmation"'); expect(initialPrompt).toContain( '"reviewInteractionId":""', @@ -270,7 +275,9 @@ describe("runner E2E catalog", () => { question?.buildPrompt("nonce"), ...breadthTasks, ]) { - const terminalTextInstruction = prompt?.match(/then emit (?:exactly|only)/)?.[0]; + const terminalTextInstruction = prompt?.match( + /then emit (?:exactly|only)/, + )?.[0]; expect(terminalTextInstruction).toBeDefined(); expect(prompt!.indexOf("paperclip_finish exactly once")).toBeLessThan( prompt!.indexOf(terminalTextInstruction!), diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index c845d216cb..b3e6f28f27 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -787,7 +787,7 @@ function warmTurnInstructions(turn: 1 | 2 | 3, nonce: string) { ? `In a legacy runner, make exactly one public-API completion write after verification: PATCH /api/issues/$PAPERCLIP_TASK_ID with {"status":"done","comment":"${marker}"}. Include Authorization and X-Paperclip-Run-Id. Do not POST a separate comment.` : `In a legacy runner, after verification POST exactly one request_confirmation to /api/issues/$PAPERCLIP_TASK_ID/interactions with {"kind":"request_confirmation","idempotencyKey":"daytona-warm-review-T${turn}-${nonce}","resolverPolicy":"human_only","title":"Warm continuity turn ${turn}","summary":"Review completed warm continuity turn ${turn}.","continuationPolicy":"wake_assignee","payload":{"version":1,"prompt":"Is this warm continuity task ready to complete after turn ${turn}?","acceptLabel":"Approve completion","rejectLabel":"Continue work","rejectRequiresReason":true,"allowDeclineReason":true,"supersedeOnUserComment":false,"target":{"type":"custom","key":"daytona_warm_turn_${turn}","revisionId":"${nonce}-T${turn}","label":"Warm continuity turn ${turn}"}}}. Capture the returned interaction id. Then make exactly one issue PATCH with {"status":"in_review","comment":"${marker}","reviewInteractionId":""}. Include Authorization and X-Paperclip-Run-Id on both writes. If the issue PATCH fails, retry only that PATCH and never create another interaction. Do not POST a separate comment. After both writes succeed, end the response and heartbeat immediately; do not wait or poll because the reviewer action will start the next turn.`; return [ - `This is warm Daytona continuity turn ${turn} of 3. Work only in the current execution workspace.`, + `This is warm Daytona continuity turn ${turn} of 3. Before reading or writing the file, run: if [ -n "\${PAPERCLIP_TASK_DIR:-}" ]; then cd "$PAPERCLIP_TASK_DIR"; fi. Use the scoped task folder when supplied; otherwise keep the current execution workspace. Do this on every turn.`, turn === 1 ? `Create ${file} with exactly this one line followed by a newline: ${lines[0]}` : `Before changing anything, read ${file} and verify its content is exactly ${lines.slice(0, -1).join("\\n")} followed by a newline. Then append exactly ${lines.at(-1)} followed by a newline.`, diff --git a/tests/runner-e2e/daytona-image.test.ts b/tests/runner-e2e/daytona-image.test.ts index 70fc7e0556..db1ea85a7e 100644 --- a/tests/runner-e2e/daytona-image.test.ts +++ b/tests/runner-e2e/daytona-image.test.ts @@ -37,12 +37,13 @@ describe("runner E2E Daytona image contract", () => { expect(normalizedDockerfile).not.toContain( "COPY packages/paperclip-runner ./packages/paperclip-runner", ); + // Branch images need the full manifest graph for workspace patches. The + // resolved lock is verified before the frozen provider dependency install. + expect(dockerfile).toContain("COPY packages ./packages"); expect(dockerfile).toContain( - "COPY packages/paperclip-eval-kernel/src ./packages/paperclip-eval-kernel/src", - ); - expect(dockerfile).toContain( - "COPY packages/paperclip-runner/src ./packages/paperclip-runner/src", + "pnpm install --resolution-only --ignore-scripts --no-frozen-lockfile", ); + expect(dockerfile).toContain("sha256sum -c /tmp/provider-lock.sha256"); expect(dockerfile).toContain( "/opt/paperclip-runner/provider-pack/provider-pack.json", ); @@ -137,20 +138,24 @@ describe("runner E2E Daytona image contract", () => { workflow.indexOf(`--format '{{json .Image}}'`), ); const providerInstall = dockerfile.indexOf( - "RUN pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'", - ); - const runnerSourceCopy = dockerfile.indexOf( - "COPY packages/paperclip-runner/src ./packages/paperclip-runner/src", + "pnpm install --frozen-lockfile --filter '@paperclipai/paperclip-runner...'", ); + const runnerSourceCopy = dockerfile.indexOf("COPY packages ./packages"); const providerRevisionArg = dockerfile.indexOf( "ARG PAPERCLIP_RUNNER_SOURCE_REVISION", ); - const cliInstall = dockerfile.indexOf("RUN npm install -g"); + const cliInstall = dockerfile.indexOf("npm install -g"); const finalMetadataArgs = dockerfile.lastIndexOf( "ARG PAPERCLIP_RUNNER_CONTENT_ID", ); expect(providerInstall).toBeGreaterThan(0); - expect(providerInstall).toBeLessThan(runnerSourceCopy); + expect(runnerSourceCopy).toBeGreaterThan(0); + expect(runnerSourceCopy).toBeLessThan(providerInstall); + const lockVerification = dockerfile.indexOf( + "sha256sum -c /tmp/provider-lock.sha256", + ); + expect(lockVerification).toBeGreaterThan(runnerSourceCopy); + expect(lockVerification).toBeLessThan(providerInstall); expect(providerInstall).toBeLessThan(providerRevisionArg); expect(cliInstall).toBeGreaterThan(0); expect(cliInstall).toBeLessThan(finalMetadataArgs); diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index bb9256d518..379cdcd214 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -19,6 +19,7 @@ import { providerSessionContinuityFailures, } from "./run-observations.js"; import { resolveRunnerE2ESource } from "./source.js"; +import { readWarmWorkspaceFile } from "./warm-workspace.js"; import { isPublicRunnerScreenshotRoute, PUBLIC_RUNNER_SCREENSHOT_MARKER, @@ -1338,10 +1339,6 @@ for (const execution of executions) { `Warm fixture ${execution.task.id} is missing its project or follow-up messages`, ); } - const workspaceFile = path.join( - workspacePath, - `daytona-warm-${nonce}.txt`, - ); const turnEvidence: Array> = []; for (const completedTurn of [1, 2] as const) { const turnDeadlineAt = Math.min( @@ -1376,10 +1373,16 @@ for (const execution of executions) { { length: completedTurn }, (_, index) => `T${index + 1}-${nonce}`, ).join("\n")}\n`; - const hostContent = await readFile(workspaceFile, "utf8"); - if (hostContent !== expectedPrefix) { + const fileObservation = await readWarmWorkspaceFile({ + api, + run: sortRunsChronologically(waitingState.taskRuns).at(-1)!, + issueId: issue.id, + workspacePath, + filename: `daytona-warm-${nonce}.txt`, + }); + if (fileObservation.content !== expectedPrefix) { throw new Error( - `Host workspace was not finalized after warm turn ${completedTurn}: expected ${JSON.stringify(expectedPrefix)}, observed ${JSON.stringify(hostContent)}`, + `Warm workspace was not finalized after turn ${completedTurn} (${fileObservation.source}): expected ${JSON.stringify(expectedPrefix)}, observed ${JSON.stringify(fileObservation.content)}`, ); } if ( @@ -1475,7 +1478,7 @@ for (const execution of executions) { turn: completedTurn, issue: waitingState.currentIssue, run: chronologicalRuns.at(-1), - hostContent, + fileObservation, leases: completedLeases, }); await page.goto( @@ -1836,12 +1839,22 @@ for (const execution of executions) { ) .map(async (matcher) => [ matcher.path, - await readFile( - path.isAbsolute(matcher.path) - ? matcher.path - : path.join(workspacePath, matcher.path), - "utf8", - ).catch(() => undefined), + execution.task.flow === "warm_three_turn" + ? ( + await readWarmWorkspaceFile({ + api, + run: finalRun, + issueId: issue!.id, + workspacePath, + filename: matcher.path, + }) + ).content + : await readFile( + path.isAbsolute(matcher.path) + ? matcher.path + : path.join(workspacePath, matcher.path), + "utf8", + ).catch(() => undefined), ]), ), ); diff --git a/tests/runner-e2e/warm-workspace.test.ts b/tests/runner-e2e/warm-workspace.test.ts new file mode 100644 index 0000000000..60d0cce2e7 --- /dev/null +++ b/tests/runner-e2e/warm-workspace.test.ts @@ -0,0 +1,134 @@ +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { readWarmWorkspaceFile } from "./warm-workspace.js"; + +const temporaryDirectories: string[] = []; +afterEach(async () => { + await Promise.all( + temporaryDirectories + .splice(0) + .map((directory) => rm(directory, { recursive: true, force: true })), + ); +}); + +async function fixture() { + const workspacePath = await mkdtemp( + path.join(os.tmpdir(), "warm-workspace-test-"), + ); + temporaryDirectories.push(workspacePath); + const binding = { + version: 1, + runId: "run", + companyId: "company", + agentId: "agent", + taskId: "task", + folders: { task: "folder" }, + }; + const saved = { + runId: "run", + state: "saved", + active: false, + lastSavedAt: "2026-09-09T04:00:00Z", + }; + const response = { + ok: () => true, + status: () => 200, + text: async () => "T1-nonce\n", + }; + const api = { + get: vi.fn().mockResolvedValue([saved]), + request: { get: vi.fn().mockResolvedValue(response) }, + }; + return { + input: { + api, + run: { + id: "run", + companyId: "company", + agentId: "agent", + contextSnapshot: { paperclipWorkFolders: binding }, + }, + issueId: "task", + workspacePath, + filename: "daytona-warm-nonce.txt", + }, + saved, + }; +} + +describe("warm workspace persistence observation", () => { + it("reads the saved scoped file without requiring a mirrored host file", async () => { + const { input } = await fixture(); + expect(await readWarmWorkspaceFile(input)).toEqual({ + source: "task-cache", + content: "T1-nonce\n", + }); + expect(input.api.get).toHaveBeenCalledWith( + "/api/companies/company/work-folders/task/task/sync", + ); + expect(input.api.request.get).toHaveBeenCalledWith( + "/api/companies/company/work-folders/task/task/content?path=daytona-warm-nonce.txt", + ); + }); + + it("does not substitute a stale host copy for a missing cached file", async () => { + const { input } = await fixture(); + await writeFile( + path.join(input.workspacePath, input.filename), + "T1-nonce\n", + ); + input.api.request.get.mockResolvedValue({ + ok: () => false, + status: () => 404, + }); + await expect(readWarmWorkspaceFile(input)).rejects.toThrow( + "download returned 404", + ); + }); + + it.each([ + { runId: "older-run" }, + { state: "failed" }, + { state: "saving" }, + { active: true }, + { lastSavedAt: null }, + ])( + "rejects incomplete or unrelated checkpoint evidence: %j", + async (override) => { + const { input, saved } = await fixture(); + input.api.get.mockResolvedValue([{ ...saved, ...override }]); + await expect(readWarmWorkspaceFile(input)).rejects.toThrow( + "successful final file save", + ); + expect(input.api.request.get).not.toHaveBeenCalled(); + }, + ); + + it.each(["runId", "companyId", "agentId", "taskId"] as const)( + "rejects a manifest with a different %s", + async (field) => { + const { input } = await fixture(); + input.run.contextSnapshot.paperclipWorkFolders[field] = "other"; + await expect(readWarmWorkspaceFile(input)).rejects.toThrow(); + expect(input.api.get).not.toHaveBeenCalled(); + }, + ); + + it("keeps the host workspace contract when the run has no scoped manifest", async () => { + const { input } = await fixture(); + await writeFile( + path.join(input.workspacePath, input.filename), + "T1-local\n", + ); + expect( + await readWarmWorkspaceFile({ + ...input, + run: { ...input.run, contextSnapshot: null }, + }), + ).toEqual({ source: "host-workspace", content: "T1-local\n" }); + expect(input.api.get).not.toHaveBeenCalled(); + expect(input.api.request.get).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/runner-e2e/warm-workspace.ts b/tests/runner-e2e/warm-workspace.ts new file mode 100644 index 0000000000..eb857ccb7c --- /dev/null +++ b/tests/runner-e2e/warm-workspace.ts @@ -0,0 +1,63 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import type { WorkFolderSyncStatus } from "../../packages/shared/src/work-folders.js"; +import type { APIResponse } from "@playwright/test"; +import type { RunnerApi } from "./api.js"; + +/** Observe the persistence contract selected by the host for this exact run. */ +export async function readWarmWorkspaceFile(input: { + api: Pick & { + request: { + get(path: string): Promise>; + }; + }; + run: { + id: string; + companyId: string; + agentId: string; + contextSnapshot?: Record | null; + }; + issueId: string; + workspacePath: string; + filename: string; +}): Promise<{ source: "task-cache" | "host-workspace"; content: string }> { + const { api, run, issueId, workspacePath, filename } = input; + assert( + /^[a-zA-Z0-9-]+\.txt$/.test(filename), + "Invalid warm fixture filename", + ); + const manifest = run.contextSnapshot?.paperclipWorkFolders; + if (manifest === undefined || manifest === null) { + return { + source: "host-workspace", + content: await readFile(path.join(workspacePath, filename), "utf8"), + }; + } + + assert(typeof manifest === "object", "Invalid host work-folder manifest"); + const binding = manifest as Record; + assert.equal(binding.version, 1); + assert.equal(binding.runId, run.id); + assert.equal(binding.companyId, run.companyId); + assert.equal(binding.agentId, run.agentId); + assert.equal(binding.taskId, issueId); + assert(binding.folders && typeof binding.folders === "object"); + assert(typeof (binding.folders as Record).task === "string"); + + const base = `/api/companies/${encodeURIComponent(run.companyId)}/work-folders/task/${encodeURIComponent(issueId)}`; + const statuses = await api.get(`${base}/sync`); + const saved = statuses.find((status) => status.runId === run.id); + assert( + saved && saved.state === "saved" && !saved.active && saved.lastSavedAt, + "The completed warm turn must have a successful final file save", + ); + const response = await api.request.get( + `${base}/content?path=${encodeURIComponent(filename)}`, + ); + assert( + response.ok(), + `Warm task file download returned ${response.status()}`, + ); + return { source: "task-cache", content: await response.text() }; +}