From d65d8315a3ea4b0361003141b5024ac3c12f8753 Mon Sep 17 00:00:00 2001 From: iamjr15 Date: Thu, 13 Aug 2026 16:03:45 +0530 Subject: [PATCH] fix(agent): stream generated media with byte ranges Forward single-range requests to R2 with correct partial-content metadata. Preserve cross-origin resource policy only for signed artifact streams so native media previews load and seek reliably. --- apps/agent-worker/README.md | 5 +- .../src/agent-api-system-routes.ts | 51 ++++++++++++++++--- apps/gateway-worker/README.md | 4 +- apps/gateway-worker/src/index.ts | 7 +++ 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/apps/agent-worker/README.md b/apps/agent-worker/README.md index 2de13307..dc3914ee 100644 --- a/apps/agent-worker/README.md +++ b/apps/agent-worker/README.md @@ -32,8 +32,9 @@ deletion RPCs terminate the run's Workflow before removing its durable state. Artifact messages persist only the output UUID and presentation metadata. The authenticated `POST /v1/outputs/:outputId/download-url` path rechecks tenant ownership, retention, and R2 existence before minting a one-hour HMAC capability; the public signed download route is only the -streaming second hop. Expiring capabilities and internal R2 keys are never stored in transcripts or -returned by artifact tools. +streaming second hop. That route forwards single HTTP byte ranges to R2 and returns `206` metadata, +so browser media previews seek and start without downloading the entire artifact. Expiring +capabilities and internal R2 keys are never stored in transcripts or returned by artifact tools. Browser screenshots use the same crash-consistent R2 persistence but are classified as internal tool evidence. Their reserved filenames keep them out of project-file and slash-command catalogs, diff --git a/apps/agent-worker/src/agent-api-system-routes.ts b/apps/agent-worker/src/agent-api-system-routes.ts index 3cf4a24e..e9d1f255 100644 --- a/apps/agent-worker/src/agent-api-system-routes.ts +++ b/apps/agent-worker/src/agent-api-system-routes.ts @@ -162,21 +162,56 @@ async function downloadOutput(c: AgentContext): Promise { }); } const output = await findDownloadableOutput(c.env, outputId, query.userId); - const object = await c.env.R2_OUTPUTS.get(output.r2Key); + const object = await c.env.R2_OUTPUTS.get(output.r2Key, { + range: c.req.raw.headers, + }); if (!object?.body) { throw new APIError(404, "resource_output_not_found", "Output object not found", { retriable: false, }); } + const headers = outputDownloadHeaders(output, object); return new Response(object.body, { - headers: { - "Cache-Control": "private, max-age=0, no-store", - "Content-Disposition": downloadContentDisposition(output.filename), - "Content-Type": output.mimeType, - "Referrer-Policy": "no-referrer", - "X-Content-Type-Options": "nosniff", - }, + headers, + status: object.range ? 206 : 200, + }); +} + +function outputDownloadHeaders( + output: { filename: string; mimeType: string }, + object: R2ObjectBody, +): Headers { + const headers = new Headers({ + "Accept-Ranges": "bytes", + "Cache-Control": "private, max-age=0, no-store", + "Content-Disposition": downloadContentDisposition(output.filename), + "Content-Type": output.mimeType, + "Cross-Origin-Resource-Policy": "cross-origin", + ETag: object.httpEtag, + "Referrer-Policy": "no-referrer", + "X-Content-Type-Options": "nosniff", }); + const range = resolveOutputRange(object.range, object.size); + headers.set("Content-Length", String(range?.length ?? object.size)); + if (range) { + headers.set("Content-Range", `bytes ${range.offset}-${range.end}/${object.size}`); + } + return headers; +} + +function resolveOutputRange( + range: R2Range | undefined, + objectSize: number, +): { end: number; length: number; offset: number } | undefined { + if (!range) return undefined; + if ("suffix" in range) { + const length = Math.min(range.suffix, objectSize); + const offset = objectSize - length; + return { end: objectSize - 1, length, offset }; + } + const offset = range.offset ?? 0; + const boundedLength = Math.min(range.length ?? objectSize - offset, objectSize - offset); + return { end: offset + boundedLength - 1, length: boundedLength, offset }; } function parseOutputId(value: string | undefined): string { diff --git a/apps/gateway-worker/README.md b/apps/gateway-worker/README.md index b5c9d43a..3a8f7e14 100644 --- a/apps/gateway-worker/README.md +++ b/apps/gateway-worker/README.md @@ -74,7 +74,9 @@ Public Clerk credentials, cookies, proxy credentials, plaintext idempotency keys caller-supplied `X-Cheatcode-*` headers terminate at the gateway. Normal service-binding requests receive only gateway-minted internal identity/idempotency headers. Artifact downloads use that boundary to mint an owner-checked short-lived URL; only the resulting HMAC-bound -streaming URL is public. Local preview traffic has a separate, explicit capability/cookie bridge. +streaming URL is public. The gateway preserves the download response's explicit cross-origin +resource policy for browser media while defaulting every other response to `same-origin`. +Local preview traffic has a separate, explicit capability/cookie bridge. Composio account sync follows provider cursors instead of treating the first page as complete, and fails closed if a user exceeds the 1,000-account safety diff --git a/apps/gateway-worker/src/index.ts b/apps/gateway-worker/src/index.ts index a8ed0d3a..0cc77d1d 100644 --- a/apps/gateway-worker/src/index.ts +++ b/apps/gateway-worker/src/index.ts @@ -64,6 +64,7 @@ const GATEWAY_SECURITY_HEADERS = { styleSrc: ["'self'", "'unsafe-inline'"], workerSrc: ["'none'"], }, + crossOriginResourcePolicy: false, referrerPolicy: "strict-origin-when-cross-origin", strictTransportSecurity: "max-age=31536000; includeSubDomains; preload", xFrameOptions: "DENY", @@ -88,6 +89,12 @@ gatewayApp.onError((error, context) => { }); gatewayApp.use("*", secureHeaders(GATEWAY_SECURITY_HEADERS)); +gatewayApp.use("*", async (c, next) => { + await next(); + if (!c.res.headers.has("Cross-Origin-Resource-Policy")) { + c.res.headers.set("Cross-Origin-Resource-Policy", "same-origin"); + } +}); gatewayApp.use("/v1/*", async (c, next) => { let handle: DatabaseHandle | undefined; c.set("database", () => {