diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index f012209580..aa35b13517 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -1198,3 +1198,8 @@ references and fabricated or cross-task delivery receipts. Text answers and accessible repository work products do not require an attachment. Publication failure calls for continued work or a concrete blocker, not a human confirmation that the task is complete. + +Local and remote runners use the same attachment publication contract. Remote +files are read through the bound environment runner, with workspace confinement, +no symlinks or hardlinks, stable file identity, a 10 MiB bound, and exact size and +SHA-256 checks before storage. Remote paths are never opened on the controller. diff --git a/server/src/services/native-runtime/native-deliverable-feedback.ts b/server/src/services/native-runtime/native-deliverable-feedback.ts index cc9364088b..08150684a8 100644 --- a/server/src/services/native-runtime/native-deliverable-feedback.ts +++ b/server/src/services/native-runtime/native-deliverable-feedback.ts @@ -15,9 +15,11 @@ export async function validateNativeDeliverableEvidence( ...result.completionClaim.criteria.flatMap(({ evidenceRefs }) => evidenceRefs), ]); for (const value of refs) { + if (typeof value !== "string") continue; const ref = value.trim(); - if (ref.startsWith("deliverable:")) { - const id = ref.slice("deliverable:".length); + const attachmentPath = /^\/api\/attachments\/([^/?#]+)\/content(?:[?#].*)?$/u.exec(ref); + if (ref.startsWith("deliverable:") || attachmentPath) { + const id = attachmentPath?.[1] ?? ref.slice("deliverable:".length); const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu; const [attachment] = uuid.test(id) ? await db.select({ id: issueAttachments.id }).from(issueAttachments) @@ -33,7 +35,7 @@ export async function validateNativeDeliverableEvidence( // URLs and typed durable refs are not workspace paths. Verification commands // belong in verification; do not scan prose or upload files named by a model. const localFile = /^(?:file:|\.{0,2}\/|[a-z]:[\\/])/iu.test(ref) - || (!/^[a-z][a-z0-9+.-]*:/iu.test(ref) && /^[^\s]+\.[a-z0-9]{1,16}(?::\d+(?::\d+)?)?$/iu.test(ref)); + || (!/^[a-z][a-z0-9+.-]*:/iu.test(ref) && /^[^\r\n]+\.[a-z0-9]{1,16}(?::\d+(?::\d+)?)?$/iu.test(ref)); if (localFile) { throw new Error("Completion cites a workspace-only file that the user cannot download. Before finishing, use register_deliverable for requested file outputs and cite deliverable: from the receipt, with /api/attachments//content as the download link. For repository changes, cite an accessible PR or registered work product instead. No human completion approval was created."); } diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index da85250004..b27c93b7a5 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -1,3 +1,4 @@ +import { readVerifiedRemoteWorkspaceFile } from "./remote-deliverable-file.js"; import { copyBackCodexAuth } from "@paperclipai/adapter-codex-local/server"; import { nativeCompletionFeedback } from "./native-completion-feedback.js"; import { hasAcknowledgedNativeStopIntent } from "../acknowledged-native-stop.js"; @@ -9915,6 +9916,20 @@ async function createRunnerdBackendWithinSessionClaim( ): Promise { let recoveryPending = retainedTransition !== undefined; const target = input.runnerExecutionTarget ?? { kind: "local" as const }; + const remoteTarget = target.kind === "remote" ? target : null; + const remoteCommandRunner = remoteTarget + ? remoteTarget.transport === "ssh" + ? createSshCommandManagedRuntimeRunner({ + spec: remoteTarget.spec, + defaultCwd: remoteTarget.remoteCwd, + }) + : remoteTarget.runner + : null; + if (remoteTarget && !remoteCommandRunner) { + throw new Error( + "runner_transport_ineligible: remote process runner is unavailable", + ); + } const currentWakeComments = await resolveCurrentWakeCommentsBinding( input.db, input.execution.binding, @@ -9934,8 +9949,11 @@ async function createRunnerdBackendWithinSessionClaim( ? input.execution.runtimeContext.mcp.digest : undefined, workMode: input.execution.task.workMode, - workspaceRoot: input.execution.workspace.cwd, + workspaceRoot: remoteTarget?.remoteCwd ?? input.execution.workspace.cwd, executionTargetKind: target.kind, + readRemoteWorkspaceFile: remoteTarget && remoteCommandRunner + ? (file) => readVerifiedRemoteWorkspaceFile({ runner: remoteCommandRunner, workspaceRoot: remoteTarget.remoteCwd, ...file }) + : undefined, currentWakeComments: currentWakeComments ?? undefined, chatAttachmentReadScope: input.chatAttachmentReadScope, enqueueWakeup: input.enqueueWakeup, @@ -9967,20 +9985,6 @@ async function createRunnerdBackendWithinSessionClaim( input.durableEnvironmentLeaseId ?? input.execution.binding.executionWorkspaceId; mkdirSync(root, { recursive: true, mode: 0o700 }); - const remoteTarget = target.kind === "remote" ? target : null; - const remoteCommandRunner = remoteTarget - ? remoteTarget.transport === "ssh" - ? createSshCommandManagedRuntimeRunner({ - spec: remoteTarget.spec, - defaultCwd: remoteTarget.remoteCwd, - }) - : remoteTarget.runner - : null; - if (remoteTarget && !remoteCommandRunner) { - throw new Error( - "runner_transport_ineligible: remote process runner is unavailable", - ); - } const remoteRuntimeRoot = remoteTarget ? posix.join( remoteTarget.remoteCwd,