diff --git a/scripts/build-preview-migrator.mjs b/scripts/build-preview-migrator.mjs index 3d920c5419..f9cc502f20 100644 --- a/scripts/build-preview-migrator.mjs +++ b/scripts/build-preview-migrator.mjs @@ -17,12 +17,19 @@ export function previewIdentity(sha, date, artifactBaseUrl) { baseUrl: `${base.href.replace(/\/$/, "")}/${sha}` }; } +export function assertPreviewSourceClean(repo) { + // Repository policy regenerates the lock in CI for manifest-only branches. + // That generated input is allowed; every tracked source input must still + // match the commit identifying both the app image and migrator artifact. + execFileSync("git", ["diff", "--quiet", "HEAD", "--", ".", ":(exclude)pnpm-lock.yaml"], { cwd: repo }); +} + export function buildPreviewMigrator(outputDirectory, artifactBaseUrl) { const repo = path.resolve(fileURLToPath(new URL("..", import.meta.url))); const git = (...args) => execFileSync("git", args, { cwd: repo, encoding: "utf8" }).trim(); const sha = git("rev-parse", "HEAD"); if (process.env.GITHUB_SHA && process.env.GITHUB_SHA !== sha) throw new Error("Preview checkout differs from the workflow commit"); - git("diff", "--quiet", "HEAD"); + assertPreviewSourceClean(repo); const identity = previewIdentity(sha, new Date(git("show", "-s", "--format=%cI", "HEAD")), artifactBaseUrl); execFileSync("pnpm", ["--filter", "@paperclipai/db...", "build"], { cwd: repo, stdio: "inherit" }); const output = path.resolve(outputDirectory); diff --git a/scripts/build-preview-migrator.test.mjs b/scripts/build-preview-migrator.test.mjs index ac3a0cccbd..d0a737d5ab 100644 --- a/scripts/build-preview-migrator.test.mjs +++ b/scripts/build-preview-migrator.test.mjs @@ -1,6 +1,28 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { previewIdentity } from "./build-preview-migrator.mjs"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { assertPreviewSourceClean, previewIdentity } from "./build-preview-migrator.mjs"; + +test("preview source identity permits CI lock resolution but rejects staged and unstaged source drift", () => { + const repo = mkdtempSync(path.join(os.tmpdir(), "preview-source-")); + const git = (...args) => execFileSync("git", args, { cwd: repo, stdio: "pipe" }); + try { + git("init"); + for (const file of ["pnpm-lock.yaml", "package.json"]) writeFileSync(path.join(repo, file), "initial\n"); + git("add", "."); + git("-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial"); + assertPreviewSourceClean(repo); + writeFileSync(path.join(repo, "pnpm-lock.yaml"), "CI resolved\n"); + assertPreviewSourceClean(repo); + writeFileSync(path.join(repo, "package.json"), "source drift\n"); + assert.throws(() => assertPreviewSourceClean(repo)); + git("add", "package.json"); + assert.throws(() => assertPreviewSourceClean(repo)); + } finally { rmSync(repo, { recursive: true, force: true }); } +}); test("preview artifact identity is immutable, namespaced, and ordered by commit time", () => { const sha = "2f42a4968d5761fd62172e35ecf8188195b8d431"; diff --git a/server/src/__tests__/environment-execution-target-capabilities.test.ts b/server/src/__tests__/environment-execution-target-capabilities.test.ts index 5a35dec846..29a660e0a1 100644 --- a/server/src/__tests__/environment-execution-target-capabilities.test.ts +++ b/server/src/__tests__/environment-execution-target-capabilities.test.ts @@ -83,14 +83,14 @@ async function buildSandboxTarget(input: { environment: { id: "env-1", driver: "sandbox", config: { provider: "daytona" } }, leaseId: "lease-1", leaseMetadata: { remoteCwd: "/work" }, - lease: { id: "lease-1", leasePolicy: "reuse_by_environment" } as never, + lease: { id: "lease-1", leasePolicy: "reuse_by_environment", metadata: { remoteCwd: "/work", marker: "preserved" } } as never, environmentRuntime, }); if (target?.kind !== "remote" || target.transport !== "sandbox") { throw new Error("expected a sandbox target"); } - return { target, execute }; + return { target, execute, environmentRuntime }; } describe("resolveEnvironmentExecutionTarget effective capability snapshot", () => { @@ -268,6 +268,23 @@ describe("effective snapshot gates the sync decision", () => { expect(target.runner?.syncOut).toBeTypeOf("function"); }); + it("uses the host-bound home for sync after work folders are prepared without changing the primary workspace", async () => { + const { target, environmentRuntime } = await buildSandboxTarget({ snapshot: FULL_GRANT, supportsSync: true }); + await target.runner!.syncOut!([]); + expect(environmentRuntime.syncOut).toHaveBeenLastCalledWith(expect.objectContaining({ + lease: expect.objectContaining({ metadata: { remoteCwd: "/work", marker: "preserved" } }), + })); + target.workFolderHome = "/home/daytona"; + await target.runner!.syncIn!([]); + await target.runner!.syncOut!([]); + for (const sync of [environmentRuntime.syncIn, environmentRuntime.syncOut]) { + expect(sync).toHaveBeenLastCalledWith(expect.objectContaining({ + lease: expect.objectContaining({ metadata: { remoteCwd: "/home/daytona", marker: "preserved" } }), + })); + } + expect(target.remoteCwd).toBe("/work"); + }); + it("omits the native sync hooks when the snapshot removes a sync verb", async () => { // The snapshot verified inbound sync but not outbound sync. The runner // exposes the sync hooks both-or-neither, so it keeps the base64 fallback. diff --git a/server/src/__tests__/sandbox-work-folders.test.ts b/server/src/__tests__/sandbox-work-folders.test.ts index ee6bdf98ae..cc515fe64a 100644 --- a/server/src/__tests__/sandbox-work-folders.test.ts +++ b/server/src/__tests__/sandbox-work-folders.test.ts @@ -40,7 +40,8 @@ describe("shared sandbox work-folder lifecycle", () => { await fs.symlink("tracked", path.join(source, "link")); await exec("git", ["-C", source, "add", "."]); await exec("git", ["-C", source, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "initial"]); - await db.insert(projectWorkspaces).values({ companyId, projectId, name, repoUrl: source, sourceType: "git_repo", isPrimary: name === "repo-one" }); + await db.insert(projectWorkspaces).values({ companyId, projectId, name, repoUrl: source, sourceType: "git_repo", isPrimary: name === "repo-one", + setupCommand: "printf 'initialized\\n' >> .setup-count" }); } }, 60_000); afterAll(async () => { @@ -62,18 +63,27 @@ describe("shared sandbox work-folder lifecycle", () => { runner: { execute: (input) => localTestWorkFolderRunner.execute({ ...input, env: { ...input.env, HOME: home } }) } } }); active.push(run); return run; } - it("keeps successful checkpoints saved when activity logging fails", async () => { + it("retains unaudited edits and retries without misreporting a completed checkpoint", async () => { + const run = await prepare(path.join(root, "activity-failure"), randomUUID()); + await run.flush(); + const [before] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, run.manifest.runId)); + await fs.writeFile(path.join(run.home, "task/activity-proof.txt"), "saved after logging recovers"); const activity = vi.spyOn(activityLog, "logActivity").mockRejectedValue(new Error("activity unavailable")); try { - const run = await prepare(path.join(root, "activity-failure"), randomUUID()); - await fs.writeFile(path.join(run.home, "task/activity-proof.txt"), "saved despite logging failure"); - await run.stop(); active.splice(active.indexOf(run), 1); + await expect(run.stop()).rejects.toThrow("activity unavailable"); const [state] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, run.manifest.runId)); - expect(state?.state).toBe("saved"); - expect(state?.lastSavedAt).not.toBeNull(); + expect(state?.state).toBe("failed"); + expect(state?.lastSavedAt).toEqual(before?.lastSavedAt); const folder = await workFolderService(db, storage).ensure({ companyId, scope: "task", ownerId: taskId }); - expect((await workFolderService(db, storage).list(folder)).files.some((file) => file.path === "activity-proof.txt")).toBe(true); + expect((await workFolderService(db, storage).list(folder)).files.some((file) => file.path === "activity-proof.txt")).toBe(false); + expect(await fs.readFile(path.join(run.home, "task/activity-proof.txt"), "utf8")).toBe("saved after logging recovers"); } finally { activity.mockRestore(); } + await run.stop(); active.splice(active.indexOf(run), 1); + const [saved] = await db.select().from(workFolderRuns).where(eq(workFolderRuns.runId, run.manifest.runId)); + expect(saved?.state).toBe("saved"); + expect(saved!.lastSavedAt!.getTime()).toBeGreaterThan(before!.lastSavedAt!.getTime()); + const folder = await workFolderService(db, storage).ensure({ companyId, scope: "task", ownerId: taskId }); + expect((await workFolderService(db, storage).list(folder)).files.some((file) => file.path === "activity-proof.txt")).toBe(true); }, 120_000); it("reuses clones and restores saved unpushed work, staged changes, and task files after losing the sandbox", async () => { @@ -85,6 +95,7 @@ describe("shared sandbox work-folder lifecycle", () => { expect(first.primaryRepo).toBe(path.join(home, "repos/repo-one")); await fs.writeFile(path.join(home, "task/report.md"), "durable task file"); const repo = first.primaryRepo; + expect(await fs.readFile(path.join(repo, ".setup-count"), "utf8")).toBe("initialized\n"); await fs.writeFile(path.join(repo, "tracked"), "committed\n"); await exec("git", ["-C", repo, "add", "."]); await exec("git", ["-C", repo, "-c", "user.name=Test", "-c", "user.email=test@example.com", "commit", "-m", "unpushed"]); @@ -95,10 +106,12 @@ describe("shared sandbox work-folder lifecycle", () => { await fs.writeFile(path.join(repo, "untracked"), "untracked\n"); await first.stop(); active.splice(active.indexOf(first), 1); const warm = await prepare(home, randomUUID(), leaseId); + expect(await fs.readFile(path.join(repo, ".setup-count"), "utf8")).toBe("initialized\n"); expect(await fs.readFile(path.join(repo, "tracked"), "utf8")).toBe("unstaged\n"); await warm.stop(); active.splice(active.indexOf(warm), 1); await fs.rm(home, { recursive: true }); const restored = await prepare(path.join(root, "replacement"), randomUUID()); + expect(await fs.readFile(path.join(restored.primaryRepo, ".setup-count"), "utf8")).toBe("initialized\n"); expect(await fs.readFile(path.join(restored.home, "task/report.md"), "utf8")).toBe("durable task file"); expect((await exec("git", ["-C", restored.primaryRepo, "rev-parse", "HEAD"])).stdout.trim()).toBe(expectedHead); expect((await exec("git", ["-C", restored.primaryRepo, "show", ":tracked"])).stdout).toBe("staged\n"); diff --git a/server/src/services/environment-execution-target.ts b/server/src/services/environment-execution-target.ts index d1aefd34f2..8166e5f925 100644 --- a/server/src/services/environment-execution-target.ts +++ b/server/src/services/environment-execution-target.ts @@ -4,6 +4,7 @@ import { adapterSupportsRemoteManagedEnvironments } from "@paperclipai/shared"; import { adapterExecutionTargetToRemoteSpec, type AdapterExecutionTarget, + type AdapterSandboxExecutionTarget, type SandboxLeaseAcquisition, } from "@paperclipai/adapter-utils/execution-target"; import type { DuplexObservabilityRecorder } from "@paperclipai/adapter-utils/duplex-observability"; @@ -359,7 +360,13 @@ export async function resolveEnvironmentExecutionTarget(input: { } } - return { + // The coordinator binds the natural home after acquiring this target. Read + // that host-owned binding at sync time instead of capturing the old cwd. + // Provider path/symlink confinement still applies to every transfer. + const syncLease = () => target.workFolderHome + ? { ...input.lease!, metadata: { ...input.lease!.metadata, remoteCwd: target.workFolderHome } } + : input.lease!; + const target: AdapterSandboxExecutionTarget = { kind: "remote", transport: "sandbox", providerKey: parsed.config.provider, @@ -595,13 +602,13 @@ export async function resolveEnvironmentExecutionTarget(input: { syncIn: (operations) => input.environmentRuntime!.syncIn({ environment: input.environment as Environment, - lease: input.lease!, + lease: syncLease(), operations, }), syncOut: (operations) => input.environmentRuntime!.syncOut({ environment: input.environment as Environment, - lease: input.lease!, + lease: syncLease(), operations, }), } @@ -625,6 +632,7 @@ export async function resolveEnvironmentExecutionTarget(input: { } : undefined, }; + return target; } if ( diff --git a/server/src/services/sandbox-work-folders.ts b/server/src/services/sandbox-work-folders.ts index 5c25f848c0..a583e9ac0b 100644 --- a/server/src/services/sandbox-work-folders.ts +++ b/server/src/services/sandbox-work-folders.ts @@ -16,7 +16,6 @@ import { workFolderRepositoryService } from "./work-folder-repositories.js"; import { startWorkFolderCheckpointer } from "./work-folder-checkpointer.js"; import { logActivity } from "./activity-log.js"; import { assertWorkFolderAccess } from "./work-folder-access.js"; -import { logger } from "../middleware/logger.js"; function signature(entry: WorkTreeEntry | undefined) { return entry ? JSON.stringify([entry.kind, entry.sha256, entry.executable]) : "missing"; @@ -228,12 +227,14 @@ export async function prepareSandboxWorkFolders(input: { const root = path.posix.join(paths.repos!, binding.name); const probe = await target.runner!.execute({ command: "git", args: ["-C", root, "rev-parse", "--git-dir"], bypassSession: true, timeoutMs: 10_000 }); const freshCheckout = probe.exitCode !== 0; + let restoredCheckout = false; if (freshCheckout) { // Publish the checkout directory only after every restore object or // clone step completes. An interrupted attempt cannot masquerade as a // reusable checkout merely because it contains a .git directory. const temporary = path.posix.join(staging, `repo-${binding.id}-${randomUUID()}`); const restored = await repositories.restore(binding, temporary, staging); + restoredCheckout = restored; if (!restored) { const auth = await resolveGitAuth(workspace.repoUrl!); const result = await target.runner!.execute({ command: "git", args: [...(auth?.configArgs ?? []), "clone", "--no-hardlinks", @@ -264,7 +265,9 @@ export async function prepareSandboxWorkFolders(input: { } await transport.moveRoot(temporary, root); } - if ((!binding.setupComplete || freshCheckout) && workspace.setupCommand) { + // A complete checkpoint already contains the setup's durable outputs. + // Replacing the sandbox must not repeat completed project setup. + if ((!binding.setupComplete || (freshCheckout && !restoredCheckout)) && workspace.setupCommand) { const setup = await target.runner!.execute({ command: "sh", args: ["-c", workspace.setupCommand], cwd: root, bypassSession: true, timeoutMs: 300_000 }); if (setup.exitCode !== 0 || setup.timedOut) throw new Error(`Repository ${binding.name} setup failed`); } @@ -285,17 +288,19 @@ export async function prepareSandboxWorkFolders(input: { await logActivity(db, { companyId: input.companyId, actorType: "agent", actorId: input.agentId, agentId: input.agentId, runId: input.runId, issueId: input.taskId, responsibleUserIdOverride: input.responsibleUserId, action, entityType: "heartbeat_run", entityId: input.runId, - details: { scopes: WORK_FOLDER_SCOPES.filter((scope) => Boolean(folders[scope])), repositories: bindings.length } }) - .catch((error) => logger.warn({ err: error, runId: input.runId }, "Work-folder activity could not be recorded")); + details: { phase: "started", scopes: WORK_FOLDER_SCOPES.filter((scope) => Boolean(folders[scope])), repositories: bindings.length } }); } try { + // Record intent before mutations. An unavailable audit store blocks new + // work instead of turning an already completed save into a false failure. + // The run's persisted state/lastSavedAt records checkpoint completion. + await recordCheckpoint("work_folder.prepared"); await seedAttachments(); await importAgentFiles(); // A resumed sandbox can hold edits newer than its last completed checkpoint. if (previous) for (const scope of WORK_FOLDER_SCOPES) await outgoing(scope); for (const scope of WORK_FOLDER_SCOPES) await incoming(scope); await prepareRepositories(); - await recordCheckpoint("work_folder.prepared"); await saveState("starting"); if (previous?.refreshRequested) await db.update(workFolderRuns).set({ refreshRequested: false }) .where(eq(workFolderRuns.runId, previous.runId)); @@ -306,10 +311,10 @@ export async function prepareSandboxWorkFolders(input: { const checkpointer = startWorkFolderCheckpointer({ async checkpoint() { await assertBindings(); + await recordCheckpoint("work_folder.checkpoint"); await saveState("saving"); for (const scope of WORK_FOLDER_SCOPES) await outgoing(scope); for (const { binding, root } of bindings) await repositories.checkpoint(binding, root); - await recordCheckpoint("work_folder.checkpoint"); await saveState("saved"); }, async onError() { await saveState("failed", "Files could not be saved; the sandbox must be retained for recovery"); },