Skip to content

Commit 44b3ac3

Browse files
committed
fix(linear): skip uploads.linear.app images in description pre-fetch
Markdown image URLs from Linear's CDN (`uploads.linear.app`) require the workspace's OAuth token to fetch — the orchestrator's URL-resolver runs unauthenticated and was 401ing, killing every Linear-with-image task before the agent ever started: Hydration failed: AttachmentResolutionError: URL attachment fetch failed with status 401: https://uploads.linear.app/… Filter Linear-hosted URLs out of the pre-fetch list. The agent picks these up at runtime via `mcp__linear-server__extract_images` (which mints fresh signed URLs the agent can fetch with `WebFetch`), so removing them from the pre-fetch path doesn't lose coverage — it just shifts the fetch from "Lambda with no auth" to "agent with the OAuth token." Trade-off: those bytes skip the input-Guardrail screening pass that runs at task-creation time. The description text is still screened. (cherry picked from commit f3bcc8e)
1 parent d41ecad commit 44b3ac3

2 files changed

Lines changed: 63 additions & 1 deletion

File tree

cdk/src/handlers/linear-webhook-processor.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1133,29 +1133,58 @@ function buildTaskDescription(issue: LinearIssueEvent['data'], contextHint: stri
11331133
* Scans for standard markdown image references: `![alt](url)`.
11341134
* Only HTTPS URLs are included (security: no HTTP, no data: URIs).
11351135
* Capped at 10 images per issue to stay within attachment limits.
1136+
*
1137+
* Linear-hosted upload URLs (`uploads.linear.app`) are SKIPPED because
1138+
* they require the workspace's OAuth token to fetch — the orchestrator's
1139+
* URL-resolver runs unauthenticated and would fail closed with 401,
1140+
* killing the task before the agent ever starts. The agent picks these
1141+
* up at runtime via `mcp__linear-server__extract_images` (which mints
1142+
* fresh signed URLs) per the on-demand prompt addendum, so dropping
1143+
* them from the pre-fetch path doesn't lose coverage — it just shifts
1144+
* the fetch from "Lambda with no auth" to "agent with the OAuth token."
1145+
*
1146+
* Trade-off: Linear-hosted images skip the Bedrock Guardrail screening
1147+
* pass that runs at task-creation time. The description text itself is
1148+
* still screened via the input guardrail; the bytes are not. Acceptable
1149+
* for now — the agent treats those images as untrusted input anyway.
11361150
*/
11371151
function extractImageUrlAttachments(description: string | undefined): Attachment[] {
11381152
if (!description) return [];
11391153

11401154
const imagePattern = /!\[[^\]]*\]\((https:\/\/[^)]+)\)/g;
11411155
const attachments: Attachment[] = [];
1156+
let skippedLinearUploads = 0;
11421157
let match: RegExpExecArray | null;
11431158

11441159
while ((match = imagePattern.exec(description)) !== null) {
11451160
if (attachments.length >= 10) break;
11461161
const url = match[1];
1162+
if (isLinearUploadsUrl(url)) {
1163+
skippedLinearUploads += 1;
1164+
continue;
1165+
}
11471166
attachments.push({ type: 'url', url });
11481167
}
11491168

1150-
if (attachments.length > 0) {
1169+
if (attachments.length > 0 || skippedLinearUploads > 0) {
11511170
logger.info('Extracted image URL attachments from Linear issue description', {
11521171
count: attachments.length,
1172+
skipped_linear_uploads: skippedLinearUploads,
11531173
});
11541174
}
11551175

11561176
return attachments;
11571177
}
11581178

1179+
function isLinearUploadsUrl(url: string): boolean {
1180+
try {
1181+
const host = new URL(url).hostname.toLowerCase();
1182+
return host === 'uploads.linear.app' || host.endsWith('.uploads.linear.app');
1183+
} catch {
1184+
return false;
1185+
}
1186+
}
1187+
11591188
async function lookupPlatformUser(workspaceId: string, userId: string): Promise<string | null> {
11601189
const key = `${workspaceId}#${userId}`;
11611190
const result = await ddb.send(new GetCommand({

cdk/test/handlers/linear-webhook-processor.test.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -508,6 +508,39 @@ describe('linear-webhook-processor handler', () => {
508508
const [reqBody] = createTaskCoreMock.mock.calls[0];
509509
expect(reqBody.attachments).toBeUndefined();
510510
});
511+
512+
test('skips uploads.linear.app images so the unauthenticated URL resolver does not 401', async () => {
513+
// Linear's CDN requires the workspace OAuth token to fetch, which the
514+
// orchestrator's URL-resolver does NOT have. The agent picks these up
515+
// at runtime via mcp__linear-server__extract_images instead, per the
516+
// Linear-channel prompt addendum.
517+
const payload = issue();
518+
const data = payload.data as Record<string, unknown>;
519+
data.description = [
520+
'![paste](https://uploads.linear.app/15d12f61/090e5ce6/938f90d7)',
521+
'![public](https://i.imgur.com/abc.png)',
522+
].join('\n');
523+
524+
await handler(eventWith(payload));
525+
526+
expect(createTaskCoreMock).toHaveBeenCalledTimes(1);
527+
const [reqBody] = createTaskCoreMock.mock.calls[0];
528+
// Only the public image survives the filter.
529+
expect(reqBody.attachments).toHaveLength(1);
530+
expect(reqBody.attachments[0].url).toBe('https://i.imgur.com/abc.png');
531+
});
532+
533+
test('drops attachments entirely when only uploads.linear.app images are present', async () => {
534+
const payload = issue();
535+
const data = payload.data as Record<string, unknown>;
536+
data.description = '![only](https://uploads.linear.app/x/y/z)';
537+
538+
await handler(eventWith(payload));
539+
540+
expect(createTaskCoreMock).toHaveBeenCalledTimes(1);
541+
const [reqBody] = createTaskCoreMock.mock.calls[0];
542+
expect(reqBody.attachments).toBeUndefined();
543+
});
511544
});
512545

513546
// ─── Linear issue context probe (paperclip attachments + project docs) ──────

0 commit comments

Comments
 (0)