Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions cdk/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
"cdk-nag": "^2.38.2",
"constructs": "^10.6.0",
"js-yaml": "^4.1.1",
"pdf-parse": "^2.4.5",
"pdf-parse": "2.4.5",
"ulid": "^3.0.2",
"ws": "^8.21.0"
},
Expand All @@ -49,7 +49,6 @@
"@types/jest": "^30.0.0",
"@types/js-yaml": "^4.0.9",
"@types/node": "^26",
"@types/pdf-parse": "^1.1.5",
"@types/ws": "^8.18.1",
"@typescript-eslint/eslint-plugin": "^8",
"@typescript-eslint/parser": "^8",
Expand Down
57 changes: 56 additions & 1 deletion cdk/src/constructs/github-screenshot-integration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,21 @@ export interface GitHubScreenshotIntegrationProps {
/**
* Optional — when provided, the processor also tries to post the
* screenshot to a linked Linear issue. Resolved from the GitHub PR
* title/body via a Linear-identifier regex (e.g. `ABCA-42`), then
* title/body via a Linear-identifier regex (e.g. `ENG-42`), then
* looked up across all `status='active'` workspaces in the registry
* via Linear's `issueVcsBranchSearch` GraphQL.
*/
readonly linearWorkspaceRegistryTable?: dynamodb.ITable;

/**
* Optional — when provided, the processor persists the captured
* screenshot's public URL onto the deploy task's TaskRecord (keyed by the
* taskId in the deploy branch), so the orchestration reconciler can
* embed the integration node's combined preview in the parent epic panel.
* Unset → persistence is skipped (the PR + Linear comments still post).
*/
readonly taskTable?: dynamodb.ITable;

/**
* Removal policy for the dedup table + screenshot bucket. Defaults
* to DESTROY so dev stacks don't accumulate orphans on `cdk destroy`.
Expand Down Expand Up @@ -192,6 +201,9 @@ export class GitHubScreenshotIntegration extends Construct {
...(props.linearWorkspaceRegistryTable && {
LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: props.linearWorkspaceRegistryTable.tableName,
}),
...(props.taskTable && {
TASK_TABLE_NAME: props.taskTable.tableName,
}),
},
bundling: commonBundling,
});
Expand Down Expand Up @@ -247,6 +259,49 @@ export class GitHubScreenshotIntegration extends Construct {
}));
}

// Write access so the processor can persist screenshot_url onto the
// deploy task's TaskRecord (conditional UpdateItem). grantWriteData covers
// the UpdateItem; the handler's update is guarded by attribute_exists.
if (props.taskTable) {
props.taskTable.grantWriteData(this.webhookProcessorFn);
// iteration-UX: on an iteration re-deploy the processor resolves the
// issue's most-recent maturing-reply id via a Query on LinearIssueIndex
// (to append the `· [preview]` link to that reply). grantWriteData does
// NOT include dynamodb:Query nor the index ARN, so grant it narrowly —
// Query on just that one GSI, not blanket grantReadData on the table.
//
// findIterationReplyId then GetItems each candidate's `head_sha` on the
// BASE table to attribute the deploy to the right iteration when several
// iterations overlap. That GetItem read needs dynamodb:GetItem on the
// base-table ARN — the Query GSI grant does NOT cover it. Without this,
// the GetItem throws AccessDenied, is swallowed non-fatally, and the
// preview is captured + posted to the PR but never appended to the Linear
// iteration reply (observed in practice — the head_sha refinement was
// added without extending its IAM grant).
this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['dynamodb:Query'],
resources: [
Stack.of(this).formatArn({
service: 'dynamodb',
resource: 'table',
resourceName: `${props.taskTable.tableName}/index/LinearIssueIndex`,
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
}),
],
}));
this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({
actions: ['dynamodb:GetItem'],
resources: [
Stack.of(this).formatArn({
service: 'dynamodb',
resource: 'table',
resourceName: props.taskTable.tableName,
arnFormat: ArnFormat.SLASH_RESOURCE_NAME,
}),
],
}));
}

// AgentCore Browser session lifecycle + automation-stream connect.
// Action set scoped to the three calls the handler actually makes;
// resource is `*` because Browser sessions are ephemeral and the
Expand Down
3 changes: 3 additions & 0 deletions cdk/src/constructs/linear-project-mapping-table.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ export interface LinearProjectMappingTableProps {
* - label_filter — Linear issue label that triggers a task (default `bgagent`)
* - status — 'active' | 'removed'
* - onboarded_at, updated_at — ISO timestamps
*
* The table is schemaless apart from the partition key, so added fields are
* additive and read with defaults — an existing row needs no migration.
*/
export class LinearProjectMappingTable extends Construct {
/**
Expand Down
92 changes: 83 additions & 9 deletions cdk/src/handlers/shared/attachment-screening.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,14 +248,42 @@ const PDF_MAX_PAGES = 50;
const PDF_MAX_TEXT_BYTES = 1024 * 1024; // 1 MB extracted text cap
const PDF_EXTRACT_TIMEOUT_MS = 15_000;

async function extractPdfText(content: Buffer, filename: string): Promise<string> {
// Dynamic import — pdf-parse is only used for PDF attachments.
/**
* pdf-parse v2 is built on pdfjs, which references browser DOM globals
* (`DOMMatrix`/`ImageData`/`Path2D`) that don't exist in the Node Lambda runtime.
* For TEXT extraction (our only use) these are never actually invoked — pdfjs only
* touches them on its optional canvas RENDER path. But if they're merely *undefined*,
* pdfjs tries to load the native `@napi-rs/canvas` binding to supply them, which
* fails on Lambda (the cross-platform native binary isn't bundled) and cascades to
* `DOMMatrix is not defined` → PDF screening unavailable (observed in practice).
*
* Defining them as inert no-op stubs makes pdfjs skip the native-canvas load path
* entirely and extract text headless — no native binary, host-independent. Verified:
* `getText` returns the full text with canvas absent + these three stubs present.
* Idempotent + non-clobbering (only fills genuinely-missing globals).
*/
function ensurePdfDomGlobals(): void {
const g = globalThis as Record<string, unknown>;
if (typeof g.DOMMatrix === 'undefined') g.DOMMatrix = class { /* inert stub — text extraction never calls it */ };
if (typeof g.ImageData === 'undefined') g.ImageData = class { /* inert stub */ };
if (typeof g.Path2D === 'undefined') g.Path2D = class { /* inert stub */ };
}

let pdfParseFn: (data: Buffer, options?: { max?: number }) => Promise<{ text: string }>;
async function extractPdfText(content: Buffer, filename: string): Promise<string> {
// pdf-parse v2 (^2.4.5) exposes a `PDFParse` CLASS — `new PDFParse({ data }).getText()` —
// NOT the v1 callable default export. Three things made this fail before:
// (1) the code called the v1 `pdfParseFn(buf)` shape (undefined on v2); (2) the
// webhook processors esbuild-bundled pdf-parse instead of shipping it via `nodeModules`,
// mangling its pdfjs/native deps; and (3) pdfjs tried to load the native
// `@napi-rs/canvas` binding for its DOM globals — absent on Lambda — instead of just
// extracting text. `ensurePdfDomGlobals` fixes (3); the bundling change fixes (2).
ensurePdfDomGlobals();
let PDFParse;
try {
// pdf-parse uses a default export; handle both CJS and ESM module shapes.
const mod = await import(/* webpackIgnore: true */ 'pdf-parse');
pdfParseFn = (mod as any).default ?? mod;
// Destructure the class from the dynamic import and let TS infer its type from
// the value — a cross-mode `typeof import('pdf-parse').PDFParse` annotation trips
// the ESM-vs-CJS dual-`.d.ts` hazard under moduleResolution:nodenext.
({ PDFParse } = await import(/* webpackIgnore: true */ 'pdf-parse'));
} catch (importErr) {
logger.error('pdf-parse module could not be imported — PDF screening unavailable', {
error: importErr instanceof Error ? importErr.message : String(importErr),
Expand All @@ -268,29 +296,75 @@ async function extractPdfText(content: Buffer, filename: string): Promise<string
}

let timeoutId: ReturnType<typeof setTimeout>;
// A TypedArray is preferred (pdf-parse transfers ownership to its worker, lowering
// main-thread memory). Slice to the exact PDF bytes so a pooled Buffer's backing
// ArrayBuffer isn't handed over wholesale.
const parser = new PDFParse({ data: new Uint8Array(content.buffer, content.byteOffset, content.byteLength) });
try {
const timeoutPromise = new Promise<never>((_, reject) => {
timeoutId = setTimeout(() => reject(new Error('PDF extraction timed out')), PDF_EXTRACT_TIMEOUT_MS);
});

// `first: N` parses only pages 1..N (the v2 page-cap knob). We cap pages +
// extracted-text bytes to bound cost/DoS — BUT the caller stores the WHOLE PDF
// and feeds it to the agent, so screening only a prefix while delivering the
// rest is a bypass (review #1 HIGH: injection on page 51 of a 51-page PDF).
// Fail CLOSED when the document exceeds what we can screen: reject rather than
// deliver unscreened pages. `result.total` is the PDF's full page count.
const result = await Promise.race([
pdfParseFn(content, { max: PDF_MAX_PAGES }),
parser.getText({ first: PDF_MAX_PAGES }),
timeoutPromise,
]);

let text: string = result.text ?? '';
const totalPages = typeof result.total === 'number' ? result.total : undefined;
if (totalPages !== undefined && totalPages > PDF_MAX_PAGES) {
throw new AttachmentScreeningError(
`PDF "${filename}" has ${totalPages} pages, over the ${PDF_MAX_PAGES}-page limit ABCA can fully ` +
'screen. Split it or attach only the relevant pages so the whole document can be checked.',
);
}
const text: string = result.text ?? '';
if (Buffer.byteLength(text, 'utf-8') > PDF_MAX_TEXT_BYTES) {
text = text.slice(0, PDF_MAX_TEXT_BYTES);
// The screened pages produced more text than we screen — we'd be delivering
// bytes we didn't fully check. Fail closed rather than truncate-and-pass.
throw new AttachmentScreeningError(
`PDF "${filename}" contains more text than ABCA can fully screen (over ${PDF_MAX_TEXT_BYTES} bytes). ` +
'Attach a smaller document so its full contents can be checked.',
);
}
return text;
} catch (err) {
// Our own over-limit / no-text rejections are already user-facing — don't
// re-wrap them as "corrupt PDF".
if (err instanceof AttachmentScreeningError) throw err;
// A DEPLOYMENT bug and a genuinely-bad PDF both land here, and they look
// nothing alike to an operator. pdf-parse mangled by esbuild (a Lambda that
// reaches this path but lacks `nodeModules: ['pdf-parse']`) throws with a
// pdfjs/DOM signature — `DOMMatrix is not defined`, `Cannot find native
// binding`, `@napi-rs/canvas`. Detect that and log a LOUD, actionable
// diagnostic (with the fix) so it's not misread as "user's PDF is corrupt" —
// that misdiagnosis has cost a full debug loop before. The user-facing
// message stays generic.
const msg = err instanceof Error ? err.message : String(err);
const looksLikeBundlingBug = /DOMMatrix|ImageData|Path2D|napi-rs\/canvas|Cannot find native binding|pdfjs/i.test(msg);
if (looksLikeBundlingBug) {
logger.error(
'PDF extraction hit a pdfjs/native-binding error — this is almost certainly a BUNDLING bug, ' +
'not a bad PDF: the Lambda screens PDFs but was esbuild-bundled without `nodeModules: [\'pdf-parse\']`. ' +
'Add the attachment-screening bundling carve-out to this function\'s construct (see the ' +
'//:check:pdf-parse-bundling guard).',
{ error: msg, filename, metric_type: 'pdf_parse_bundling_error' },
);
}
throw new AttachmentScreeningError(
`PDF "${filename}" could not be processed. It may be corrupt or use unsupported features. ` +
'Try exporting to a simpler PDF format.',
{ cause: err },
);
} finally {
clearTimeout(timeoutId!);
// Release the pdfjs worker/document (v2 holds native + worker handles).
await parser.destroy().catch(() => { /* best-effort teardown */ });
}
}

Expand Down
Loading
Loading