diff --git a/server/src/services/native-runtime/native-runner-file-handoff.test.ts b/server/src/services/native-runtime/native-runner-file-handoff.test.ts index 614b6931df4..9bf2720b67d 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.test.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.test.ts @@ -602,6 +602,40 @@ describe("native runner file handoff", () => { .rejects.toThrow(/size|hash/); }); + it("reads provider files before mutation locks and replays without reading again", async () => { + const body = Buffer.from("Provider callback output\n"); + const reader = vi.fn(async () => { + await db.transaction(async tx => { + await tx.execute(sql`set local lock_timeout = '500ms'`); + await tx.select({ id: agents.id }).from(agents).where(eq(agents.id, agentId)).for("share"); + }); + return body; + }); + const remote = authority({ executionTargetKind: "remote", readRemoteTaskFile: reader }); + const call = callFor("proof.txt", body, "provider-lock-callback"); + const result = await remote.execute(call); + reader.mockRejectedValueOnce(new Error("The original file is no longer available")); + await expect(remote.execute(call)).resolves.toEqual(result); + expect(reader).toHaveBeenCalledTimes(1); + }); + + it("rejects publication if agent authority changes during a provider read", async () => { + const body = Buffer.from("Revoked output\n"); + const before = await db.select({ id: issueAttachments.id }).from(issueAttachments); + const reader = vi.fn(async () => { + await db.update(agents).set({ status: "paused" }).where(eq(agents.id, agentId)); + return body; + }); + try { + await expect(authority({ executionTargetKind: "remote", readRemoteTaskFile: reader }) + .execute(callFor("proof.txt", body, "revoked-during-read"))) + .rejects.toThrow("paperclip_runner_tool_binding_not_authorized"); + expect(await db.select({ id: issueAttachments.id }).from(issueAttachments)).toEqual(before); + } finally { + await db.update(agents).set({ status: "active" }).where(eq(agents.id, agentId)); + } + }); + it("registers an absolute file only through a task-location-authorizing reader", async () => { const body=Buffer.from("Home output\n");const reader=vi.fn(async()=>body); const call=callFor("/home/agent/proof.txt",body,"home-output"); diff --git a/server/src/services/native-runtime/native-runner-file-handoff.ts b/server/src/services/native-runtime/native-runner-file-handoff.ts index ef9607ccb9e..6f2c32beec8 100644 --- a/server/src/services/native-runtime/native-runner-file-handoff.ts +++ b/server/src/services/native-runtime/native-runner-file-handoff.ts @@ -298,7 +298,7 @@ function sameFileIdentity(left: Stats, right: Stats): boolean { ); } -async function readVerifiedWorkspaceFile( +export async function readVerifiedWorkspaceFile( binding: NativeRunnerFileHandoffBinding, input: NativeRunnerFileHandoffInput, ): Promise { @@ -1141,14 +1141,11 @@ async function assertCurrentBinding( export async function prepareNativeRunnerFileHandoff(input: { readonly db: Db; readonly binding: NativeRunnerFileHandoffBinding; - readonly deliverable: NativeRunnerFileHandoffInput; + readonly verifiedFile: VerifiedWorkspaceFile; readonly storage?: StorageService; }): Promise { const { statusVersion } = await assertCurrentBinding(input.db, input.binding); - const verified = await readVerifiedWorkspaceFile( - input.binding, - input.deliverable, - ); + const verified = input.verifiedFile; const [existing] = await input.db .select({ diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 49ae704e623..56bccadf0b6 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -94,7 +94,7 @@ import { getNativeReviewAssignment, readNativeReviewAssignmentContext, type Nati import { childReviewOutcomes } from "./child-review-outcomes.js"; import { logActivity, persistActivity, publishActivity } from "../activity-log.js"; import { captureRunIdentity } from "../run-identity.js"; -import { prepareNativeRunnerFileHandoff, type RemoteWorkspaceFileReader } from "./native-runner-file-handoff.js"; +import { prepareNativeRunnerFileHandoff, readVerifiedWorkspaceFile, type RemoteWorkspaceFileReader } from "./native-runner-file-handoff.js"; import { MAX_ATTACHMENT_BYTES } from "../../attachment-types.js"; import { READ_CURRENT_WAKE_COMMENTS_TOOL_DEFINITION, @@ -1927,6 +1927,35 @@ export class PaperclipRunnerToolAuthority { if (!workspaceRoot) { throw new Error("paperclip_runner_file_handoff_workspace_unavailable"); } + const current = await this.#boundContext(); + const authorizeWorkspace = async (actor: typeof agents.$inferSelect) => { + if (this.binding.dotRuntime) { + if (!this.binding.workspaceBridge || actor.adapterConfig?.dotWorkspaceAccess !== true) + throw forbidden("Dot workspace access is required to publish workspace files."); + assertRunnerWorkspacePathAllowed(typeof input.contentRef === "string" ? input.contentRef : ""); + await this.binding.assertBridgeAuthority?.(); + } + }; + await authorizeWorkspace(current.actor); + const fileBinding = { + companyId: this.binding.companyId, + issueId: this.binding.issueId, + runId: this.binding.runId, + agentId: this.binding.agentId, + workspaceRoot, + executionTargetKind: this.binding.executionTargetKind ?? "local", + readRemoteWorkspaceFile: this.binding.readRemoteWorkspaceFile, + readRemoteTaskFile: this.binding.readRemoteTaskFile, + } as const; + const prior = record(record(current.run.resultJson).semanticToolReceipts)[idempotencyKey]; + const verifiedFile = prior === undefined ? await readVerifiedWorkspaceFile(fileBinding, { + filename: typeof input.filename === "string" ? input.filename : "", + contentType: typeof input.contentType === "string" ? input.contentType : "", + byteSize: typeof input.byteSize === "number" ? input.byteSize : Number.NaN, + sha256: typeof input.sha256 === "string" ? input.sha256 : "", + contentRef: typeof input.contentRef === "string" ? input.contentRef : "", + title: typeof input.title === "string" ? input.title : "", + }) : null; let publication: Awaited>["publication"] | null = null; let rollbackDefinitePreCommitFailure: (() => Promise) | null = null; @@ -1935,35 +1964,12 @@ export class PaperclipRunnerToolAuthority { idempotencyKey, input, async (tx, context) => { - if (this.binding.dotRuntime) { - if (!this.binding.workspaceBridge || context.actor.adapterConfig?.dotWorkspaceAccess !== true) - throw forbidden("Dot workspace access is required to publish workspace files."); - assertRunnerWorkspacePathAllowed(typeof input.contentRef === "string" ? input.contentRef : ""); - await this.binding.assertBridgeAuthority?.(); - } + await authorizeWorkspace(context.actor); + if (!verifiedFile) throw new Error("paperclip_runner_file_handoff_receipt_changed"); const prepared = await prepareNativeRunnerFileHandoff({ db: tx, - binding: { - companyId: this.binding.companyId, - issueId: this.binding.issueId, - runId: this.binding.runId, - agentId: this.binding.agentId, - workspaceRoot, - executionTargetKind: this.binding.executionTargetKind ?? "local", - readRemoteWorkspaceFile: this.binding.readRemoteWorkspaceFile, - readRemoteTaskFile: this.binding.readRemoteTaskFile, - }, - deliverable: { - filename: typeof input.filename === "string" ? input.filename : "", - contentType: - typeof input.contentType === "string" ? input.contentType : "", - byteSize: - typeof input.byteSize === "number" ? input.byteSize : Number.NaN, - sha256: typeof input.sha256 === "string" ? input.sha256 : "", - contentRef: - typeof input.contentRef === "string" ? input.contentRef : "", - title: typeof input.title === "string" ? input.title : "", - }, + binding: fileBinding, + verifiedFile, storage: this.binding.storage, }); rollbackDefinitePreCommitFailure =