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 `` markdown in the issue
41+ * images inline as `` 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
4753import * as dns from 'dns/promises' ;
4854import * as net from 'net' ;
4955import { 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' ;
5157import { estimateImageTokensFromBuffer } from './image-tokens' ;
5258import { logger } from './logger' ;
5359import { 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. */
5864const 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. */
6167const MAX_LINEAR_UPLOADS_PER_ISSUE = 10 ;
6268
6369/** Max length of the derived, path-safe attachment id (S3 key segment). */
6470const 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 . */
6874const PNG_MAGIC = [ 0x89 , 0x50 , 0x4e , 0x47 ] as const ;
6975const 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: ``. 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+ * `` 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 = / ! \[ [ ^ \] ] * \] \( ( h t t p s : \/ \/ [ ^ ) ] + ) \) / g;
87+ const MARKDOWN_LINK_OR_IMAGE_PATTERN = / ! ? \[ [ ^ \] ] * \] \( ( h t t p s : \/ \/ [ ^ ) ] + ) \) / 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. */
109133interface 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 - Z a - z 0 - 9 _ - ] / g, '-' ) . replace ( / - + / g, '-' ) . replace ( / ^ - | - $ / g, '' ) || `upload-${ index } ` ) . slice ( 0 , MAX_ATTACHMENT_ID_LENGTH ) ;
179203 const sanitized = lastSegment . replace ( / [ ^ a - z A - Z 0 - 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+ * `` 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 */
190223function 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
0 commit comments