1818 */
1919
2020import * as crypto from 'crypto' ;
21+ import { BedrockRuntimeClient } from '@aws-sdk/client-bedrock-runtime' ;
2122import { DynamoDBClient } from '@aws-sdk/client-dynamodb' ;
23+ import { S3Client } from '@aws-sdk/client-s3' ;
2224import { DynamoDBDocumentClient , GetCommand , UpdateCommand } from '@aws-sdk/lib-dynamodb' ;
25+ import { ulid } from 'ulid' ;
26+ import type { ScreeningConfig } from './shared/attachment-screening' ;
2327import { buildClarifyResumeDescription , isClarifyHold } from './shared/clarify-resume' ;
2428import { createTaskCore } from './shared/create-task-core' ;
2529import { renderMaturingReply } from './shared/iteration-reply' ;
30+ import { cleanupPreScreenedAttachments , downloadScreenAndStoreLinearAttachments , LinearAttachmentError } from './shared/linear-attachments' ;
2631import {
2732 deleteComment ,
2833 reactToComment ,
@@ -107,7 +112,7 @@ import { computeEpicRetryPlan } from './shared/orchestration-reconcile';
107112import { readConcurrencyBudget , releaseReadyChildren } from './shared/orchestration-release' ;
108113import { upsertEpicPanel } from './shared/orchestration-rollup' ;
109114import { claimCommentAck , clearRollupClaim , deriveOrchestrationId , loadOrchestration , setStatusCommentId , type OrchestrationReleaseContext } from './shared/orchestration-store' ;
110- import type { Attachment } from './shared/types' ;
115+ import type { Attachment , PassedAttachmentRecord } from './shared/types' ;
111116import { CODING_WORKFLOW_ID } from './shared/workflows' ;
112117
113118const ddb = DynamoDBDocumentClient . from ( new DynamoDBClient ( { } ) ) ;
@@ -124,6 +129,20 @@ const DEFAULT_LABEL_FILTER = 'bgagent';
124129// budget. Unset → release all roots (back-compat; admission still gates).
125130const USER_CONCURRENCY_TABLE = process . env . USER_CONCURRENCY_TABLE_NAME ;
126131const MAX_CONCURRENT = Number ( process . env . MAX_CONCURRENT_TASKS_PER_USER ?? '10' ) ;
132+ // Attachment enrichment (ADR-016): fetch uploads.linear.app images with the
133+ // workspace OAuth token at admission time, screen, store, inject as
134+ // preScreenedAttachments — Linear has no MCP so the agent can't fetch them.
135+ // Mirrors the Jira processor (#619). Absent env → the authenticated-fetch path
136+ // is off (the public-URL image path in extractImageUrlAttachments still runs).
137+ const ATTACHMENTS_BUCKET = process . env . ATTACHMENTS_BUCKET_NAME ;
138+ const GUARDRAIL_ID = process . env . GUARDRAIL_ID ;
139+ const GUARDRAIL_VERSION = process . env . GUARDRAIL_VERSION ;
140+ const attachmentsS3Client = ATTACHMENTS_BUCKET ? new S3Client ( { } ) : undefined ;
141+ const attachmentsBedrockClient = GUARDRAIL_ID && GUARDRAIL_VERSION ? new BedrockRuntimeClient ( { } ) : undefined ;
142+ const attachmentsScreeningConfig : ScreeningConfig | undefined =
143+ attachmentsBedrockClient && GUARDRAIL_ID && GUARDRAIL_VERSION
144+ ? { bedrockClient : attachmentsBedrockClient , guardrailId : GUARDRAIL_ID , guardrailVersion : GUARDRAIL_VERSION }
145+ : undefined ;
127146// #299 Mode B: TTL (seconds) for a persisted pending plan awaiting approval. A
128147// week is ample for a human to approve; the row self-expires after.
129148const PENDING_PLAN_TTL_SECONDS = 604_800 ;
@@ -883,10 +902,69 @@ export async function handler(event: ProcessorEvent): Promise<void> {
883902
884903 const taskDescription = buildTaskDescription ( issue , contextHint ) ;
885904
886- // Extract embedded image URLs from the issue description markdown.
887- // These become URL attachments that are fetched and screened during context hydration.
905+ // Extract embedded image URLs from the issue description markdown. Non-Linear
906+ // (public CDN) images become URL attachments fetched+screened during context
907+ // hydration; uploads.linear.app images are handled below (they need auth).
888908 const attachments = extractImageUrlAttachments ( issue . description ) ;
889909
910+ // Mint the taskId up-front so pre-screened attachment S3 keys match the
911+ // eventual task record (createTaskCore honors ctx.taskId). Mirrors Jira #619.
912+ const taskId = ulid ( ) ;
913+
914+ // ADR-016: fetch uploads.linear.app images with the workspace OAuth token,
915+ // screen, store, and inject as preScreenedAttachments — the agent has no
916+ // Linear MCP to fetch them at runtime. Fail-closed: a selected-but-
917+ // unscreenable attachment rejects the whole task (mirrors the Jira processor).
918+ let preScreenedAttachments : PassedAttachmentRecord [ ] = [ ] ;
919+ if ( resolvedAccessToken && issue . description && issue . description . includes ( 'uploads.linear.app' ) ) {
920+ if ( ! attachmentsS3Client || ! ATTACHMENTS_BUCKET || ! attachmentsScreeningConfig ) {
921+ logger . error ( 'Linear issue has uploads.linear.app attachments but screening/storage is not configured (fail-closed)' , {
922+ issue_id : issue . id ,
923+ linear_workspace_id : workspaceId ,
924+ has_bucket : Boolean ( ATTACHMENTS_BUCKET ) ,
925+ has_guardrail : Boolean ( attachmentsScreeningConfig ) ,
926+ } ) ;
927+ await safeReportIssueFailure (
928+ issue . id ,
929+ workspaceId ,
930+ '❌ This Linear issue has uploaded image attachments, but ABCA attachment screening is not configured. Contact your ABCA admin.' ,
931+ ) ;
932+ return ;
933+ }
934+ // Combined cap: public-URL image attachments already consume slots.
935+ const remainingSlots = 10 - attachments . length ;
936+ try {
937+ preScreenedAttachments = await downloadScreenAndStoreLinearAttachments (
938+ issue . description ,
939+ remainingSlots ,
940+ {
941+ s3Client : attachmentsS3Client ,
942+ bucketName : ATTACHMENTS_BUCKET ,
943+ screeningConfig : attachmentsScreeningConfig ,
944+ userId : platformUserId ,
945+ taskId,
946+ accessToken : resolvedAccessToken ,
947+ linearWorkspaceId : workspaceId ,
948+ } ,
949+ ) ;
950+ } catch ( err ) {
951+ if ( err instanceof LinearAttachmentError ) {
952+ logger . warn ( 'Rejecting Linear task: attachment could not be safely processed' , {
953+ issue_id : issue . id ,
954+ linear_workspace_id : workspaceId ,
955+ error : err . message ,
956+ } ) ;
957+ await safeReportIssueFailure (
958+ issue . id ,
959+ workspaceId ,
960+ `❌ ABCA couldn't safely process an attachment on this issue: ${ err . message } Remove or fix the attachment and re-apply the trigger label.` ,
961+ ) ;
962+ return ;
963+ }
964+ throw err ;
965+ }
966+ }
967+
890968 const requestId = crypto . randomUUID ( ) ;
891969 const result = await createTaskCore (
892970 {
@@ -903,6 +981,8 @@ export async function handler(event: ProcessorEvent): Promise<void> {
903981 userId : platformUserId ,
904982 channelSource : 'linear' ,
905983 channelMetadata,
984+ taskId,
985+ ...( preScreenedAttachments . length > 0 && { preScreenedAttachments } ) ,
906986 } ,
907987 requestId ,
908988 ) ;
@@ -913,6 +993,11 @@ export async function handler(event: ProcessorEvent): Promise<void> {
913993 body : result . body ,
914994 issue_id : issue . id ,
915995 } ) ;
996+ // Don't orphan the attachment objects we uploaded before this call failed —
997+ // createTaskCore only rolls back its own inline uploads, not ours.
998+ if ( preScreenedAttachments . length > 0 && attachmentsS3Client && ATTACHMENTS_BUCKET ) {
999+ await cleanupPreScreenedAttachments ( attachmentsS3Client , ATTACHMENTS_BUCKET , preScreenedAttachments ) ;
1000+ }
9161001 await safeReportIssueFailure (
9171002 issue . id ,
9181003 workspaceId ,
0 commit comments