Skip to content

Commit be79d34

Browse files
committed
feat(linear): support all platform attachment types, not just images
Extend linear-attachments.ts beyond PNG/JPEG to the full platform allowlist (PDF, text, csv, markdown, json, log). Linear attaches uploaded files as plain [label](uploads.linear.app/…) links (not inline images), so the description scan now matches both the image ![]() and link []() forms. MIME is resolved content-type → extension → magic-bytes (incl %PDF-), and non-image types are screened via screenTextFile, mirroring jira-attachments (aws-samples#619). Unsupported types (docx, zip, …) are silently skipped rather than failing the task; images/files/PDF still magic-byte validated and fail-closed. Adds file-type tests (PDF link, .log octet-stream, unsupported-skip).
1 parent b795e89 commit be79d34

2 files changed

Lines changed: 152 additions & 40 deletions

File tree

cdk/src/handlers/shared/linear-attachments.ts

Lines changed: 102 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,24 @@
2222
*
2323
* ABCA runs Linear 100% deterministically — there is NO Linear MCP (see
2424
* ADR-016 "Linear is fully deterministic"). The agent therefore cannot fetch
25-
* `uploads.linear.app`-hosted images at runtime (that used to be
25+
* `uploads.linear.app`-hosted files at runtime (that used to be
2626
* `mcp__linear-server__extract_images`). Instead, the webhook processor fetches
2727
* them here at admission time, AUTHENTICATED with the workspace `@bgagent`
2828
* OAuth token, screens each through the same Bedrock Guardrail pipeline as
2929
* every other attachment, uploads the cleaned bytes to S3, and returns `passed`
3030
* AttachmentRecords for `createTaskCore` to persist verbatim.
3131
*
32+
* All platform-supported attachment types come through here, not just images:
33+
* images (PNG/JPEG) are screened visually, files (PDF/text/csv/markdown/json/
34+
* log) as text — same set the inline/URL paths and `jira-attachments.ts` allow.
35+
* Types outside the allowlist (docx, zip, …) are silently skipped, not errored.
36+
*
3237
* This is the Linear analog of `jira-attachments.ts` (#619) — same
3338
* select → fetch → magic-bytes → screen → upload → record shape, same
3439
* fail-closed contract ({@link LinearAttachmentError}), same batch cleanup. The
3540
* one Linear-specific difference is the FETCH primitive: Linear embeds uploaded
36-
* images as `![alt](https://uploads.linear.app/…)` markdown in the issue
41+
* images inline as `![alt](https://uploads.linear.app/…)` and attaches uploaded
42+
* files as plain `[label](https://uploads.linear.app/…)` links in the issue
3743
* description, and those signed URLs require the workspace OAuth bearer (the
3844
* unauthenticated URL-resolver in `resolve-url-attachments.ts` deliberately
3945
* SKIPS `uploads.linear.app` for exactly this reason — see
@@ -47,7 +53,7 @@
4753
import * as dns from 'dns/promises';
4854
import * as net from 'net';
4955
import { PutObjectCommand, DeleteObjectsCommand, type S3Client } from '@aws-sdk/client-s3';
50-
import { screenImage, AttachmentScreeningError, type ScreeningConfig } from './attachment-screening';
56+
import { screenImage, screenTextFile, AttachmentScreeningError, type ScreeningConfig } from './attachment-screening';
5157
import { estimateImageTokensFromBuffer } from './image-tokens';
5258
import { logger } from './logger';
5359
import { createAttachmentRecord, type PassedAttachmentRecord } from './types';
@@ -57,24 +63,42 @@ import { ATTACHMENT_OBJECT_KEY_PREFIX } from '../../constructs/attachments-bucke
5763
/** Per-request timeout for a single attachment download. */
5864
const ATTACHMENT_FETCH_TIMEOUT_MS = 10_000;
5965

60-
/** Cap on `uploads.linear.app` images pulled from one issue description. */
66+
/** Cap on `uploads.linear.app` files pulled from one issue description. */
6167
const MAX_LINEAR_UPLOADS_PER_ISSUE = 10;
6268

6369
/** Max length of the derived, path-safe attachment id (S3 key segment). */
6470
const MAX_ATTACHMENT_ID_LENGTH = 128;
6571

6672
/* eslint-disable @typescript-eslint/no-magic-numbers -- file-format magic-byte signatures */
67-
/** PNG/JPEG magic-byte signatures used to sniff a generic-content-type body. */
73+
/** Magic-byte signatures used to sniff a body when the content-type is generic. */
6874
const PNG_MAGIC = [0x89, 0x50, 0x4e, 0x47] as const;
6975
const JPEG_MAGIC = [0xff, 0xd8, 0xff] as const;
76+
const PDF_MAGIC = [0x25, 0x50, 0x44, 0x46, 0x2d] as const; // %PDF-
7077
/* eslint-enable @typescript-eslint/no-magic-numbers */
7178

7279
/**
73-
* Markdown image reference: `![alt](https://…)`. Mirrors the pattern in
74-
* `linear-webhook-processor.extractImageUrlAttachments` so the two stay in
75-
* lockstep about what counts as an embedded image.
80+
* Markdown reference to a `uploads.linear.app` file. Matches BOTH the image form
81+
* `![alt](url)` AND the plain link form `[label](url)` — Linear embeds uploaded
82+
* images inline (`!`) but attaches uploaded files (PDFs, logs, specs) as plain
83+
* links. The leading `!` is optional so both are captured. Mirrors the image
84+
* pattern in `linear-webhook-processor.extractImageUrlAttachments` (which only
85+
* needs the `!` form for the public-CDN URL path).
7686
*/
77-
const MARKDOWN_IMAGE_PATTERN = /!\[[^\]]*\]\((https:\/\/[^)]+)\)/g;
87+
const MARKDOWN_LINK_OR_IMAGE_PATTERN = /!?\[[^\]]*\]\((https:\/\/[^)]+)\)/g;
88+
89+
/** Extension → MIME for typing a Linear upload when the response content-type
90+
* is generic (e.g. application/octet-stream). Only platform-allowed types. */
91+
const EXTENSION_TO_MIME: Record<string, string> = {
92+
png: 'image/png',
93+
jpg: 'image/jpeg',
94+
jpeg: 'image/jpeg',
95+
txt: 'text/plain',
96+
log: 'text/x-log',
97+
csv: 'text/csv',
98+
md: 'text/markdown',
99+
json: 'application/json',
100+
pdf: 'application/pdf',
101+
};
78102

79103
/**
80104
* Thrown when a Linear attachment that was SELECTED for inclusion cannot be
@@ -105,7 +129,7 @@ export interface LinearAttachmentStorage {
105129
readonly linearWorkspaceId: string;
106130
}
107131

108-
/** A `uploads.linear.app` image selected from the description for download. */
132+
/** A `uploads.linear.app` file selected from the description for download. */
109133
interface SelectedUpload {
110134
readonly url: string;
111135
/** Path-traversal-safe, unique filename for the S3 key + on-disk name. */
@@ -177,15 +201,24 @@ function deriveUploadIdentity(url: string, index: number): { id: string; filenam
177201
// re-signed URL for the same object maps to the same id). Bounded length.
178202
const id = (pathname.replace(/[^A-Za-z0-9_-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '') || `upload-${index}`).slice(0, MAX_ATTACHMENT_ID_LENGTH);
179203
const sanitized = lastSegment.replace(/[^a-zA-Z0-9._-]/g, '_');
180-
const filename = isValidFilename(sanitized) ? sanitized : `linear-upload-${index}.png`;
204+
// No .png default: a link-form upload may be a PDF/log. A generic fallback name
205+
// keeps the extension out of the type decision (content-type/magic-bytes win).
206+
const filename = isValidFilename(sanitized) ? sanitized : `linear-upload-${index}`;
181207
return { id, filename };
182208
}
183209

210+
/** Lowercased file extension (no dot) of a filename, or '' if none. */
211+
function extensionOf(filename: string): string {
212+
const dot = filename.lastIndexOf('.');
213+
return dot > 0 ? filename.slice(dot + 1).toLowerCase() : '';
214+
}
215+
184216
/**
185-
* Scan an issue description for `uploads.linear.app` markdown images, capped at
217+
* Scan an issue description for `uploads.linear.app` markdown files (both image
218+
* `![](url)` and link `[](url)` forms), capped at
186219
* {@link MAX_LINEAR_UPLOADS_PER_ISSUE} and the remaining per-task slot budget.
187-
* Non-Linear-hosted images are ignored here (the unauthenticated URL path in
188-
* `resolve-url-attachments.ts` handles those). De-dupes by id.
220+
* Non-Linear-hosted URLs are ignored here (the unauthenticated URL path in
221+
* `resolve-url-attachments.ts` handles public images). De-dupes by id.
189222
*/
190223
function selectLinearUploads(description: string | undefined, remainingSlots: number): SelectedUpload[] {
191224
if (!description) return [];
@@ -196,11 +229,11 @@ function selectLinearUploads(description: string | undefined, remainingSlots: nu
196229
const seenIds = new Set<string>();
197230
let index = 0;
198231
let match: RegExpExecArray | null;
199-
MARKDOWN_IMAGE_PATTERN.lastIndex = 0;
200-
while ((match = MARKDOWN_IMAGE_PATTERN.exec(description)) !== null) {
232+
MARKDOWN_LINK_OR_IMAGE_PATTERN.lastIndex = 0;
233+
while ((match = MARKDOWN_LINK_OR_IMAGE_PATTERN.exec(description)) !== null) {
201234
if (selected.length >= slotCap) break;
202235
const url = match[1];
203-
if (!isLinearUploadsUrl(url)) continue; // public CDN images go via the URL path
236+
if (!isLinearUploadsUrl(url)) continue; // public CDN URLs go via the URL path
204237
const { id, filename } = deriveUploadIdentity(url, index++);
205238
if (seenIds.has(id)) continue;
206239
seenIds.add(id);
@@ -288,7 +321,7 @@ async function fetchUploadBytes(
288321
}
289322

290323
/**
291-
* Fetch, screen, and store the `uploads.linear.app` images embedded in an issue
324+
* Fetch, screen, and store the `uploads.linear.app` files embedded in an issue
292325
* description, returning `passed` AttachmentRecords for `createTaskCore` to
293326
* persist verbatim.
294327
*
@@ -349,26 +382,42 @@ export async function downloadScreenAndStoreLinearAttachments(
349382
throw new LinearAttachmentError(`Attachment '${upload.filename}' is empty (0 bytes).`);
350383
}
351384

352-
// Infer the image MIME from the response content-type; fall back to
353-
// sniffing the magic bytes. Only images are embedded via uploads.linear.app
354-
// markdown, and screenImage + the allowlist enforce PNG/JPEG.
355-
const mimeType = inferImageMime(outcome.contentType, content);
356-
if (!mimeType || !isAllowedMimeType(mimeType, 'image')) {
357-
throw new LinearAttachmentError(
358-
`Attachment '${upload.filename}' is not a supported image type (${outcome.contentType || 'unknown'}).`,
359-
);
385+
// Infer the MIME from the response content-type, the filename extension,
386+
// then the magic bytes (in that order of trust). Linear embeds uploaded
387+
// images inline and attaches uploaded files (PDFs, logs, CSVs, JSON, text)
388+
// as links — both come through here. The platform allowlist gates the
389+
// supported set: images (PNG/JPEG) and files (PDF/text/csv/markdown/json/
390+
// log). Anything else (docx, zip, …) is silently skipped, matching the
391+
// pre-download filter contract — an unsupported upload is not a task error.
392+
const mimeType = inferMime(outcome.contentType, upload.filename, content);
393+
const isImage = mimeType.startsWith('image/');
394+
const attachmentType = isImage ? 'image' : 'file';
395+
if (!mimeType || !isAllowedMimeType(mimeType, attachmentType)) {
396+
logger.info('Skipping unsupported Linear upload type', {
397+
linear_workspace_id: ctx.linearWorkspaceId,
398+
attachment_filename: upload.filename,
399+
content_type: outcome.contentType || 'unknown',
400+
inferred_mime: mimeType || 'unknown',
401+
});
402+
continue;
360403
}
404+
// Confirm the bytes match the resolved type (blocks a masquerading/polyglot
405+
// payload). Text types have no signature — validateMagicBytes checks for
406+
// valid, null-free UTF-8 instead.
361407
if (!validateMagicBytes(content, mimeType)) {
362408
throw new LinearAttachmentError(
363-
`Attachment '${upload.filename}' content does not match its declared image type '${mimeType}'.`,
409+
`Attachment '${upload.filename}' content does not match its declared type '${mimeType}'.`,
364410
);
365411
}
366412

367413
// Screen through the same Bedrock Guardrail pipeline as every other
368-
// attachment. Any block or screening failure is fail-closed.
414+
// attachment — images visually, files as text. Any block or screening
415+
// failure is fail-closed.
369416
let screenResult;
370417
try {
371-
screenResult = await screenImage(content, mimeType, upload.filename, ctx.screeningConfig);
418+
screenResult = isImage
419+
? await screenImage(content, mimeType, upload.filename, ctx.screeningConfig)
420+
: await screenTextFile(content, mimeType, upload.filename, ctx.screeningConfig);
372421
} catch (err) {
373422
if (err instanceof AttachmentScreeningError) {
374423
throw new LinearAttachmentError(
@@ -411,17 +460,23 @@ export async function downloadScreenAndStoreLinearAttachments(
411460
}
412461
uploadedKeys.push(s3Key);
413462

463+
// Only images carry a token estimate (files aren't fed to the model as
464+
// vision tokens). Mirrors jira-attachments.
465+
const tokenEstimate = isImage
466+
? estimateImageTokensFromBuffer(screenResult.content, mimeType)
467+
: undefined;
468+
414469
records.push(createAttachmentRecord({
415470
attachment_id: upload.id,
416-
type: 'image',
471+
type: attachmentType,
417472
content_type: mimeType,
418473
filename: upload.filename,
419474
s3_key: s3Key,
420475
s3_version_id: putResult.VersionId ?? 'unversioned',
421476
size_bytes: screenResult.content.length,
422477
screening: { status: 'passed', screened_at: new Date().toISOString() },
423478
checksum_sha256: screenResult.checksum,
424-
token_estimate: estimateImageTokensFromBuffer(screenResult.content, mimeType),
479+
...(tokenEstimate !== undefined && { token_estimate: tokenEstimate }),
425480
}) as PassedAttachmentRecord);
426481

427482
logger.info('Linear attachment downloaded, screened, and stored', {
@@ -445,12 +500,24 @@ function startsWith(content: Buffer, magic: readonly number[]): boolean {
445500
return magic.every((byte, i) => content[i] === byte);
446501
}
447502

448-
/** PNG/JPEG magic bytes → MIME, preferring the response content-type when it
449-
* is an allowed image type, else sniffing. Returns '' if neither yields one. */
450-
function inferImageMime(contentType: string, content: Buffer): string {
451-
if (contentType === 'image/png' || contentType === 'image/jpeg') return contentType;
503+
/**
504+
* Resolve the MIME type of a downloaded upload, in descending order of trust:
505+
* 1. the response content-type, if it is itself a platform-allowed type;
506+
* 2. the filename extension (covers generic `application/octet-stream`
507+
* responses — common for Linear file links to PDFs/logs/CSVs);
508+
* 3. a recognised binary magic-byte signature (PNG/JPEG/PDF).
509+
* Returns '' if none applies (caller silently skips the upload). Text types are
510+
* never sniffed from bytes — they rely on the extension in step 2.
511+
*/
512+
function inferMime(contentType: string, filename: string, content: Buffer): string {
513+
if (contentType && (isAllowedMimeType(contentType, 'image') || isAllowedMimeType(contentType, 'file'))) {
514+
return contentType;
515+
}
516+
const byExt = EXTENSION_TO_MIME[extensionOf(filename)];
517+
if (byExt) return byExt;
452518
if (startsWith(content, PNG_MAGIC)) return 'image/png';
453519
if (startsWith(content, JPEG_MAGIC)) return 'image/jpeg';
520+
if (startsWith(content, PDF_MAGIC)) return 'application/pdf';
454521
return '';
455522
}
456523

cdk/test/handlers/shared/linear-attachments.test.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,11 +18,13 @@
1818
*/
1919

2020
const screenImageMock = jest.fn();
21+
const screenTextFileMock = jest.fn();
2122
jest.mock('../../../src/handlers/shared/attachment-screening', () => {
2223
const actual = jest.requireActual('../../../src/handlers/shared/attachment-screening');
2324
return {
2425
...actual,
2526
screenImage: (...args: unknown[]) => screenImageMock(...args),
27+
screenTextFile: (...args: unknown[]) => screenTextFileMock(...args),
2628
};
2729
});
2830

@@ -42,6 +44,8 @@ import { MAX_ATTACHMENT_SIZE_BYTES } from '../../../src/handlers/shared/validati
4244

4345
const PNG_BYTES = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00]);
4446
const JPEG_BYTES = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]);
47+
const PDF_BYTES = Buffer.from('%PDF-1.7\n1 0 obj\n<<>>\nendobj\n');
48+
const TEXT_BYTES = Buffer.from('log line one\nlog line two\n');
4549

4650
const putSendMock = jest.fn();
4751
const s3Client = { send: putSendMock } as unknown as import('@aws-sdk/client-s3').S3Client;
@@ -68,6 +72,10 @@ const UPLOAD_URL = 'https://uploads.linear.app/aaaa-1111/bbbb-2222/screenshot.pn
6872
function desc(...urls: string[]): string {
6973
return `Some issue text\n\n${urls.map((u, i) => `![img${i}](${u})`).join('\n')}\n\nmore text`;
7074
}
75+
/** Description with plain-link (file) markdown `[label](url)` rather than image `![]()`. */
76+
function fileDesc(...urls: string[]): string {
77+
return `Some issue text\n\n${urls.map((u, i) => `[file${i}](${u})`).join('\n')}\n\nmore text`;
78+
}
7179

7280
/** A fetch Response-like object whose body streams the given buffer once. */
7381
function bytesResponse(buf: Buffer, status = 200, contentType = 'image/png'): Response {
@@ -98,6 +106,12 @@ beforeEach(() => {
98106
checksum: 'sha256:abc',
99107
screening: { status: 'passed', screened_at: '2026-07-22T00:00:00Z' },
100108
}));
109+
screenTextFileMock.mockReset();
110+
screenTextFileMock.mockImplementation((content: Buffer) => Promise.resolve({
111+
content,
112+
checksum: 'sha256:def',
113+
screening: { status: 'passed', screened_at: '2026-07-22T00:00:00Z' },
114+
}));
101115
putSendMock.mockReset();
102116
putSendMock.mockResolvedValue({ VersionId: 'v1' });
103117
dnsLookupMock.mockReset();
@@ -192,11 +206,42 @@ describe('downloadScreenAndStoreLinearAttachments', () => {
192206
expect(screenImageMock).not.toHaveBeenCalled();
193207
});
194208

195-
test('non-image content-type with no image magic bytes → LinearAttachmentError', async () => {
196-
(global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(Buffer.from('hello'), 200, 'text/plain'));
197-
await expect(
198-
downloadScreenAndStoreLinearAttachments(desc(UPLOAD_URL), 10, storageCtx()),
199-
).rejects.toBeInstanceOf(LinearAttachmentError);
209+
test('fetches a PDF file link, screens it as text, returns a file record', async () => {
210+
(global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(PDF_BYTES, 200, 'application/pdf'));
211+
const records = await downloadScreenAndStoreLinearAttachments(
212+
fileDesc('https://uploads.linear.app/u/p/design.pdf'), 10, storageCtx(),
213+
);
214+
expect(records).toHaveLength(1);
215+
expect(records[0].type).toBe('file');
216+
expect(records[0].content_type).toBe('application/pdf');
217+
expect(records[0].token_estimate).toBeUndefined(); // files don't carry a vision-token estimate
218+
expect(screenTextFileMock).toHaveBeenCalled();
219+
expect(screenImageMock).not.toHaveBeenCalled();
220+
expect(putSendMock).toHaveBeenCalledTimes(1);
221+
});
222+
223+
test('types a generic octet-stream response by its .log extension and screens as text', async () => {
224+
(global.fetch as jest.Mock).mockResolvedValueOnce(bytesResponse(TEXT_BYTES, 200, 'application/octet-stream'));
225+
const records = await downloadScreenAndStoreLinearAttachments(
226+
fileDesc('https://uploads.linear.app/u/l/output.log'), 10, storageCtx(),
227+
);
228+
expect(records).toHaveLength(1);
229+
expect(records[0].type).toBe('file');
230+
expect(records[0].content_type).toBe('text/x-log');
231+
expect(screenTextFileMock).toHaveBeenCalled();
232+
});
233+
234+
test('silently SKIPS an unsupported type (docx/zip) — not a task error', async () => {
235+
(global.fetch as jest.Mock).mockResolvedValueOnce(
236+
bytesResponse(Buffer.from([0x50, 0x4b, 0x03, 0x04]), 200, 'application/zip'),
237+
);
238+
const records = await downloadScreenAndStoreLinearAttachments(
239+
fileDesc('https://uploads.linear.app/u/z/bundle.zip'), 10, storageCtx(),
240+
);
241+
expect(records).toHaveLength(0);
242+
expect(screenImageMock).not.toHaveBeenCalled();
243+
expect(screenTextFileMock).not.toHaveBeenCalled();
244+
expect(putSendMock).not.toHaveBeenCalled();
200245
});
201246

202247
test('sniffs JPEG when content-type is generic but bytes are a JPEG', async () => {

0 commit comments

Comments
 (0)