diff --git a/server/src/__tests__/file-resources.test.ts b/server/src/__tests__/file-resources.test.ts index b3097dfa398..12c32f8e7fd 100644 --- a/server/src/__tests__/file-resources.test.ts +++ b/server/src/__tests__/file-resources.test.ts @@ -235,6 +235,10 @@ describeEmbeddedPostgres("workspace file resources", () => { await expect(service.readContent(graph.issueId, { path: ".env", workspace: "execution" })).rejects.toMatchObject({ status: 403 }); await expect(service.readContent(graph.issueId, { path: "../other-company/secret", workspace: "execution" })).rejects.toMatchObject({ status: 403 }); expect(readBytes).not.toHaveBeenCalled(); + workspaceFiles.mockClear(); + const readOnly = workspaceFileResourceService(db, undefined, { allowComputerAccess: false }); + await expect(readOnly.prepareDownload(graph.issueId, { path: "README.md", workspace: "execution" })).rejects.toMatchObject({ status: 422, details: { code: "remote_workspace" } }); + expect(workspaceFiles).not.toHaveBeenCalled(); } finally { factory.mockRestore(); await fs.rm(workspace.root, { recursive: true, force: true }); } }); diff --git a/server/src/services/customer-success-inspection.ts b/server/src/services/customer-success-inspection.ts index 2f9a031e82a..7ca0ad0da68 100644 --- a/server/src/services/customer-success-inspection.ts +++ b/server/src/services/customer-success-inspection.ts @@ -329,7 +329,7 @@ export async function readCustomerSuccessResource( if (q.operation.startsWith("files.")) { try { const issue = await ownRow(tx, schema.issues, q.companyId!, q.resourceId!); - const files = workspaceFileResourceService(tx); + const files = workspaceFileResourceService(tx, undefined, { allowComputerAccess: false }); const input = { path: q.path ?? "", ...q.context, limit: q.limit, offset: q.offset }; const opts = { issue: issue as unknown as NonNullable[2]>["issue"], @@ -338,6 +338,17 @@ export async function readCustomerSuccessResource( if (q.operation === "files.read") return await files.readContent(q.resourceId!, input, opts); const resolved = await files.prepareDownload(q.resourceId!, input, opts); + if ("bytes" in resolved) { + const bytes = resolved.bytes; + if (bytes.length === 0 && !q.range) return binary(bytes, 0); + const start = q.range?.start ?? 0; + const end = q.range?.end ?? bytes.length - 1; + if (start >= bytes.length || end >= bytes.length || end - start + 1 > MAX_INSPECTION_BYTES) + throw unprocessable("Request a bounded byte range"); + // The remote backend already captured a bounded, confined snapshot; + // do not reinterpret its path on the controller filesystem. + return binary(bytes.subarray(start, end + 1), bytes.length, start); + } const file = await fs.open(resolved.realPath, constants.O_RDONLY | constants.O_NOFOLLOW); try { const stat = await file.stat(); 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 de950a6b7ab..55f6324f6be 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -919,6 +919,10 @@ export class PaperclipRunnerToolAuthority { return { bytes: Buffer.concat(chunks), filename: asset.originalFilename ?? "file", contentType: asset.contentType }; } const resolved = await workspaceFileResourceService(this.db).prepareDownload(this.binding.issueId, { path: file.path!, workspace: "auto" }); + if ("bytes" in resolved) { + if (resolved.bytes.length > RUNNER_API_MAX_BYTES) throw badRequest("Workspace file exceeds API transfer limit"); + return { bytes: resolved.bytes, filename: resolved.resource.title, contentType: resolved.resource.contentType ?? "application/octet-stream" }; + } const handle = await openRunnerApiWorkspaceFile(resolved.realPath); try { const stat = await handle.stat(); diff --git a/server/src/services/workspace-file-resources.ts b/server/src/services/workspace-file-resources.ts index 3fc821018d3..f7443619580 100644 --- a/server/src/services/workspace-file-resources.ts +++ b/server/src/services/workspace-file-resources.ts @@ -1049,8 +1049,11 @@ async function listChangedWorkspaceFiles(input: { }; } -export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor) { +export function workspaceFileResourceService(db: Db, actor?: AuthorizationActor, options: { allowComputerAccess?: boolean } = {}) { async function placementFiles(candidate: WorkspaceCandidate) { + // Computer reads acquire a bounded machine owner and may resume it. Pure + // inspection transactions must remain read-only, including provider effects. + if (options.allowComputerAccess === false) throw unprocessable("Computer access is unavailable in read-only inspection", { code: "remote_workspace" }); if (!candidate.placement) throw unprocessable("Workspace placement is unavailable", { code: "remote_workspace" }); return computerFileAccess(() => computerService(db).workspaceFiles(candidate.placement!)); }