diff --git a/cdk/package.json b/cdk/package.json index e3d71a2e..fb68ed53 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -17,6 +17,7 @@ "@aws-cdk/aws-bedrock-agentcore-alpha": "2.238.0-alpha.0", "@aws-cdk/aws-bedrock-alpha": "2.238.0-alpha.0", "@aws-cdk/mixins-preview": "2.238.0-alpha.0", + "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/client-bedrock-agentcore": "^3.1046.0", "@aws-sdk/client-bedrock-runtime": "^3.1021.0", "@aws-sdk/client-ecs": "^3.1021.0", @@ -24,14 +25,18 @@ "@aws-sdk/client-lambda": "^3.1021.0", "@aws-sdk/client-s3": "^3.1021.0", "@aws-sdk/client-secrets-manager": "^3.1021.0", + "@aws-sdk/credential-provider-node": "^3.972.29", "@aws-sdk/lib-dynamodb": "^3.1021.0", "@aws-sdk/s3-request-presigner": "^3.1021.0", "@aws/durable-execution-sdk-js": "^1.1.0", "@cedar-policy/cedar-wasm": "4.10.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.14", "aws-cdk-lib": "^2.238.0", "cdk-nag": "^2.37.55", "constructs": "^10.3.0", - "ulid": "^3.0.2" + "ulid": "^3.0.2", + "ws": "^8.18.0" }, "devDependencies": { "@cdklabs/eslint-plugin": "^1.5.10", @@ -39,6 +44,7 @@ "@types/aws-lambda": "^8.10.161", "@types/jest": "^30.0.0", "@types/node": "^20", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "aws-cdk": "^2", diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts new file mode 100644 index 00000000..98ba3a8e --- /dev/null +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -0,0 +1,267 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as path from 'path'; +import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda'; +import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; +import { ScreenshotBucket } from './screenshot-bucket'; + +/** + * Properties for GitHubScreenshotIntegration construct. + */ +export interface GitHubScreenshotIntegrationProps { + /** The existing REST API to add the GitHub webhook route to. */ + readonly api: apigw.RestApi; + + /** + * Existing GitHub PAT secret. The processor reuses ABCA's main GitHub + * token to (a) look up which PR a deploy SHA belongs to via the + * Commits API, and (b) post the screenshot comment on that PR. + * No new GitHub credential is provisioned by this construct. + */ + readonly githubTokenSecret: secretsmanager.ISecret; + + /** + * 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 + * looked up across all `status='active'` workspaces in the registry + * via Linear's `issueVcsBranchSearch` GraphQL. + */ + readonly linearWorkspaceRegistryTable?: dynamodb.ITable; + + /** + * Removal policy for the dedup table + screenshot bucket. Defaults + * to DESTROY so dev stacks don't accumulate orphans on `cdk destroy`. + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Override for the deploy environment we screenshot. Defaults to + * `Preview` (Vercel's label for per-PR deploys). Set this when + * targeting a different deploy backend. + * @default 'Preview' + */ + readonly screenshotTargetEnvironment?: string; +} + +/** + * CDK construct that adds the GitHub-deployment-status → screenshot → + * PR-comment pipeline. + * + * Topology mirrors `LinearIntegration`: + * - Receiver Lambda (HMAC-verifies, dedups, async-invokes processor) + * - Async processor Lambda (drives AgentCore Browser, uploads PNG, + * posts the PR comment) + * - Dedup DynamoDB table (1h TTL — covers GitHub's 5-attempt retry + * window with slack) + * - Webhook signing-secret (Secrets Manager placeholder; populated + * manually when the operator pastes GitHub's value into the secret) + * - Public-read screenshot S3 bucket + * - API Gateway route `POST /v1/github/webhook` + * + * Inbound-only adapter — there's no outbound polling or stream + * consumer, just the webhook → screenshot → comment fan-out. + */ +export class GitHubScreenshotIntegration extends Construct { + /** Public-read bucket hosting the screenshot PNGs. */ + public readonly screenshotBucket: ScreenshotBucket; + + /** + * GitHub webhook signing secret — placeholder. The operator pastes + * GitHub's signing-secret value here after configuring the webhook + * in the demo repo's settings; the secret is otherwise empty. + */ + public readonly webhookSecret: secretsmanager.Secret; + + /** Webhook dedup table (composite key = `repo#deployment_id#status_id`). */ + public readonly webhookDedupTable: dynamodb.Table; + + /** Webhook receiver Lambda (HMAC verifier + dispatcher). */ + public readonly webhookFn: lambda.NodejsFunction; + + /** Async processor Lambda (browser + S3 + PR comment). */ + public readonly webhookProcessorFn: lambda.NodejsFunction; + + constructor(scope: Construct, id: string, props: GitHubScreenshotIntegrationProps) { + super(scope, id); + + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; + + // --- Screenshot bucket (public-read on `screenshots/*`) --- + this.screenshotBucket = new ScreenshotBucket(this, 'ScreenshotBucket', { + removalPolicy, + }); + + // --- Webhook signing secret (operator-populated placeholder) --- + this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', { + description: 'GitHub deployment-status webhook signing secret — populate manually after configuring the GitHub webhook', + removalPolicy, + }); + + // --- Dedup table --- + this.webhookDedupTable = new dynamodb.Table(this, 'WebhookDedupTable', { + partitionKey: { name: 'dedup_key', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true }, + removalPolicy, + }); + + const handlersDir = path.join(__dirname, '..', 'handlers'); + const commonBundling: lambda.BundlingOptions = { + externalModules: ['@aws-sdk/*'], + }; + + // --- Async processor (browser + S3 + comment) --- + // Timeout budget: 60s screenshot + 5s navigate slack + 30s slack for + // the GitHub PR-lookup + comment + S3 PUT + JSON encode = 95s. Round + // to 120 for headroom on cold-start CDP handshake. + this.webhookProcessorFn = new lambda.NodejsFunction(this, 'WebhookProcessorFn', { + entry: path.join(handlersDir, 'github-webhook-processor.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(120), + memorySize: 512, + environment: { + SCREENSHOT_BUCKET_NAME: this.screenshotBucket.bucket.bucketName, + SCREENSHOT_PUBLIC_HOST: this.screenshotBucket.distribution.domainName, + GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, + ...(props.linearWorkspaceRegistryTable && { + LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: props.linearWorkspaceRegistryTable.tableName, + }), + }, + bundling: commonBundling, + }); + + this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn); + props.githubTokenSecret.grantRead(this.webhookProcessorFn); + + // Optional Linear feedback path. Wired only when a registry table + // is provided. The processor scans the registry for active + // workspaces, then per-workspace looks up the OAuth token from + // Secrets Manager (`bgagent-linear-oauth-*` prefix, written by + // `bgagent linear setup`). + if (props.linearWorkspaceRegistryTable) { + props.linearWorkspaceRegistryTable.grantReadData(this.webhookProcessorFn); + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-linear-oauth-*', + }), + ], + })); + } + + // AgentCore Browser session lifecycle + automation-stream connect. + // The data-plane API doesn't support per-resource ARNs (sessions + // are ephemeral), so wildcards are required — annotated with a + // cdk-nag suppression below. The wildcard set covers + // `ConnectBrowserAutomationStream` (the SigV4-presigned WSS dial) + // which lives under the same prefix but isn't visible in the + // public CLI command list. + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['bedrock-agentcore:*'], + resources: ['*'], + })); + + // --- Webhook receiver (verify, dedup, dispatch) --- + this.webhookFn = new lambda.NodejsFunction(this, 'WebhookFn', { + entry: path.join(handlersDir, 'github-webhook.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + GITHUB_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn, + GITHUB_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName, + GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NAME: this.webhookProcessorFn.functionName, + ...(props.screenshotTargetEnvironment && { + SCREENSHOT_TARGET_ENVIRONMENT: props.screenshotTargetEnvironment, + }), + }, + bundling: commonBundling, + }); + + this.webhookSecret.grantRead(this.webhookFn); + this.webhookDedupTable.grantReadWriteData(this.webhookFn); + this.webhookProcessorFn.grantInvoke(this.webhookFn); + + // --- API Gateway route --- + const githubResource = props.api.root.addResource('github'); + const webhookResource = githubResource.addResource('webhook'); + const webhookMethod = webhookResource.addMethod( + 'POST', + new apigw.LambdaIntegration(this.webhookFn), + { authorizationType: apigw.AuthorizationType.NONE }, + ); + + NagSuppressions.addResourceSuppressions(webhookMethod, [ + { + id: 'AwsSolutions-APIG4', + reason: 'GitHub webhook endpoint authenticates via X-Hub-Signature-256 HMAC, not Cognito — required by GitHub webhook protocol.', + }, + { + id: 'AwsSolutions-COG4', + reason: 'GitHub webhook endpoint authenticates via X-Hub-Signature-256 HMAC, not Cognito — required by GitHub webhook protocol.', + }, + ]); + + NagSuppressions.addResourceSuppressions(this.webhookFn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the standard managed policy for Lambda CloudWatch Logs writes.', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'DynamoDB grants from CDK helpers expand to table-arn/index/* wildcards; receiver only writes to the dedup table.', + }, + ], true); + + NagSuppressions.addResourceSuppressions(this.webhookProcessorFn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the standard managed policy for Lambda CloudWatch Logs writes.', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'AgentCore Browser sessions are ephemeral and have no per-resource ARN; the data-plane API requires wildcards. S3 PutObject uses CDK grant helpers that expand to bucket/* wildcards.', + }, + ], true); + + NagSuppressions.addResourceSuppressions(this.webhookSecret, [ + { + id: 'AwsSolutions-SMG4', + reason: 'GitHub webhook signing-secret rotation is owned by GitHub (operator regenerates on the GitHub side and pastes the new value here). No automated rotation Lambda needed.', + }, + ]); + } +} diff --git a/cdk/src/constructs/screenshot-bucket.ts b/cdk/src/constructs/screenshot-bucket.ts new file mode 100644 index 00000000..76c4b6b7 --- /dev/null +++ b/cdk/src/constructs/screenshot-bucket.ts @@ -0,0 +1,152 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Duration, RemovalPolicy } from 'aws-cdk-lib'; +import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; +import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; + +/** Lifecycle expiry for screenshot artifacts. */ +export const SCREENSHOT_TTL_DAYS = 30; + +/** + * Object-key prefix for all screenshots. Key layout: + * ``screenshots//.png``. The CloudFront distribution serves + * the entire bucket, but the processor only ever writes under this + * prefix. + */ +export const SCREENSHOT_KEY_PREFIX = 'screenshots/'; + +/** + * Properties for ScreenshotBucket construct. + */ +export interface ScreenshotBucketProps { + /** + * Removal policy for the bucket + distribution. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to auto-delete objects when the bucket is removed. + * @default true + */ + readonly autoDeleteObjects?: boolean; +} + +/** + * Private S3 bucket fronted by a CloudFront distribution that serves + * screenshot PNGs to GitHub Markdown / Linear render pipelines. + * + * Why CloudFront and not a public-read bucket: the AWS account-level + * Block Public Access is on (S3 control plane refuses to attach any + * public bucket policy), and disabling it would change the security + * posture of the whole account. CloudFront with Origin Access Control + * is the AWS-recommended path for "S3 object served anonymously over + * HTTPS." Bucket stays fully private; only the distribution principal + * has GetObject. + * + * Layout: + * s3:///screenshots//.png (private) + * https://.cloudfront.net/screenshots//.png (anon) + * + * The 30-day lifecycle on the bucket is the source of truth for + * expiry — CloudFront's edge caches will see 403s after the TTL + * lapses, which is fine for stale PR comments. + */ +export class ScreenshotBucket extends Construct { + /** The underlying private S3 bucket. */ + public readonly bucket: s3.Bucket; + + /** CloudFront distribution serving the bucket anonymously. */ + public readonly distribution: cloudfront.Distribution; + + constructor(scope: Construct, id: string, props: ScreenshotBucketProps = {}) { + super(scope, id); + + this.bucket = new s3.Bucket(this, 'Bucket', { + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + lifecycleRules: [ + { + id: 'screenshot-ttl', + enabled: true, + expiration: Duration.days(SCREENSHOT_TTL_DAYS), + abortIncompleteMultipartUploadAfter: Duration.days(1), + }, + ], + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + autoDeleteObjects: props.autoDeleteObjects ?? true, + }); + + // CloudFront → S3 via Origin Access Control. The bucket policy is + // generated automatically by `S3BucketOrigin.withOriginAccessControl` + // and grants `s3:GetObject` to the distribution's CF service principal + // only — no anonymous principal in the policy, so account-level BPA + // doesn't reject it. + this.distribution = new cloudfront.Distribution(this, 'Distribution', { + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + // Screenshots are immutable per (repo, sha) — long TTL is safe + // and minimizes origin S3 requests on hot PRs. + cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, + allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD, + }, + // No alternate domain or ACM cert — the default + // *.cloudfront.net hostname is fine for a backend artifact host. + enableLogging: false, + comment: 'ABCA screenshot artifacts (private S3 + OAC)', + }); + + NagSuppressions.addResourceSuppressions(this.bucket, [ + { + id: 'AwsSolutions-S1', + reason: + 'Server access logs are not enabled for this bucket; screenshots are ephemeral artifacts (30-day TTL) embedded in GitHub PR comments. Adding access logging would generate substantial log volume for a low-value security signal.', + }, + ], true); + + NagSuppressions.addResourceSuppressions(this.distribution, [ + { + id: 'AwsSolutions-CFR1', + reason: 'No geo restrictions are needed — screenshots are referenced from GitHub.com which is global; restricting origins would break cross-region PR reviewers.', + }, + { + id: 'AwsSolutions-CFR2', + reason: 'AWS WAF is not attached to this distribution. The content is read-only PNGs of preview deploys; no app logic, no input handling, no auth — WAF would only add cost without reducing risk.', + }, + { + id: 'AwsSolutions-CFR3', + reason: 'Access logs are not enabled on the distribution for the same reason as the bucket — low-value high-volume signal for ephemeral artifacts.', + }, + { + id: 'AwsSolutions-CFR4', + reason: 'Distribution uses the default *.cloudfront.net certificate (TLSv1+ enforced by AWS). No custom domain, so no minimum-TLS-version override needed.', + }, + { + id: 'AwsSolutions-CFR7', + reason: 'OAC is in use (the construct calls `S3BucketOrigin.withOriginAccessControl`). cdk-nag misclassifies the L2 helper as an OAI deployment.', + }, + ], true); + } +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index c35f76f2..1491d4ec 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -258,22 +258,42 @@ export class TaskApi extends Construct { vendorName: 'AWS', name: 'AWSManagedRulesCommonRuleSet', // Inbound webhook payloads from mature SaaS tools (Linear ships - // full Issue payloads > 8 KB) trip SizeRestrictions_BODY in this - // ruleset. Exempt the Linear webhook path from CRS entirely: - // the route is HMAC-verified in the Lambda, parsed as strict + // full Issue payloads > 8 KB; GitHub deployment_status carries + // absolute deploy URLs flagged by GenericRFI_BODY) trip + // SizeRestrictions_BODY / GenericRFI_BODY in this ruleset. + // Exempt the Linear and GitHub webhook paths from CRS entirely: + // both routes are HMAC-verified in the Lambda, parsed as strict // JSON, never interpolated into SQL/HTML, and rate-limited by // the priority-3 rule below. CRS still applies to every other // route (user API, Slack, etc.). scopeDownStatement: { - notStatement: { - statement: { - byteMatchStatement: { - fieldToMatch: { uriPath: {} }, - positionalConstraint: 'EXACTLY', - searchString: '/v1/linear/webhook', - textTransformations: [{ priority: 0, type: 'NONE' }], + andStatement: { + statements: [ + { + notStatement: { + statement: { + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/linear/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, + }, }, - }, + { + notStatement: { + statement: { + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/github/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, + }, + }, + ], }, }, }, diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts new file mode 100644 index 00000000..31a88eb8 --- /dev/null +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -0,0 +1,376 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { captureScreenshot } from './shared/agentcore-browser'; +import { resolveGitHubToken } from './shared/context-hydration'; +import { upsertTaskComment } from './shared/github-comment'; +import { postIssueComment } from './shared/linear-feedback'; +import { extractLinearIdentifier, findLinearIssueByIdentifier } from './shared/linear-issue-lookup'; +import { logger } from './shared/logger'; + +const s3 = new S3Client({}); + +const SCREENSHOT_BUCKET = process.env.SCREENSHOT_BUCKET_NAME!; +// CloudFront distribution domain — `.cloudfront.net`. Used as +// the public host for the screenshot URL embedded in PR comments. +// The bucket is private; CloudFront with OAC reads on the agent's +// behalf. +const SCREENSHOT_PUBLIC_HOST = process.env.SCREENSHOT_PUBLIC_HOST!; +const GITHUB_TOKEN_SECRET_ARN = process.env.GITHUB_TOKEN_SECRET_ARN!; +// Optional — when set, the processor also tries to post the +// screenshot comment onto a linked Linear issue. Resolved from the +// GitHub PR title/body via a Linear-identifier regex (e.g. `ABCA-42`), +// then looked up across all active workspaces in the registry. +const LINEAR_WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; + +interface GitHubDeploymentStatusPayload { + readonly action?: string; + readonly deployment_status?: { + readonly id?: number; + readonly state?: string; + readonly target_url?: string; + /** The deployed URL — lives on the *status* object, not the deployment. */ + readonly environment_url?: string; + }; + readonly deployment?: { + readonly id?: number; + readonly sha?: string; + readonly environment?: string; + }; + readonly repository?: { + readonly full_name?: string; + }; +} + +interface ProcessorEvent { + readonly raw_body: string; +} + +/** + * Async processor for verified GitHub `deployment_status` webhooks. + * + * Flow: + * 1. Parse the payload (already validated as deployment_status by the + * receiver, but we re-extract the fields we need). + * 2. Find the open PR for the deploy SHA via the GitHub Commits API. + * 3. Capture a screenshot of `deployment.environment_url` via + * AgentCore Browser. + * 4. PUT the PNG to the screenshot bucket. + * 5. POST a fresh PR comment with `![preview]()`. + * + * Every external call is best-effort. If any step fails, log + return — + * the receiver already 200'd, so retries by GitHub will dedup at the + * receiver layer. + */ +export async function handler(event: ProcessorEvent): Promise { + if (!event.raw_body) { + logger.error('GitHub webhook processor invoked without raw_body'); + return; + } + + let payload: GitHubDeploymentStatusPayload; + try { + payload = JSON.parse(event.raw_body) as GitHubDeploymentStatusPayload; + } catch (err) { + logger.error('GitHub webhook processor could not parse raw_body', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const repo = payload.repository?.full_name; + const sha = payload.deployment?.sha; + // The URL lives on `deployment_status` (it changes per status update — + // `pending` has no URL, `success` fills it in), not on `deployment`. + const previewUrl = payload.deployment_status?.environment_url; + const deploymentId = payload.deployment?.id; + + if (!repo || !sha || !previewUrl) { + logger.warn('GitHub deployment_status payload missing required fields', { + repo, + sha_present: Boolean(sha), + preview_url_present: Boolean(previewUrl), + deployment_id: deploymentId, + }); + return; + } + + logger.info('Screenshot pipeline starting', { + repo, + sha, + preview_url: previewUrl, + deployment_id: deploymentId, + }); + + let token: string; + try { + token = await resolveGitHubToken(GITHUB_TOKEN_SECRET_ARN); + } catch (err) { + logger.error('Failed to resolve GitHub token; cannot post screenshot comment', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + // Race: Vercel posts `deployment_status` the moment its build finishes, + // which can be ~5-15s before the agent calls `gh pr create` for the + // same SHA. Retry the PR lookup with a small backoff so the screenshot + // doesn't get silently dropped on what is the common path. + const pr = await findPullRequestForShaWithRetry(repo, sha, token); + if (!pr) { + logger.info('No open PR found for SHA after retries — skipping screenshot post', { repo, sha }); + return; + } + + let png: Uint8Array; + try { + png = await captureScreenshot(previewUrl); + } catch (err) { + logger.error('Screenshot capture failed', { + preview_url: previewUrl, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const key = buildScreenshotKey(repo, sha, deploymentId); + try { + await s3.send(new PutObjectCommand({ + Bucket: SCREENSHOT_BUCKET, + Key: key, + Body: png, + ContentType: 'image/png', + Metadata: { + repo, + sha, + // S3 metadata values must be ASCII; coerce numeric to string and + // skip the URL itself (URL encoding into x-amz-meta-* is brittle). + deployment_id: String(deploymentId ?? ''), + }, + })); + } catch (err) { + logger.error('Failed to upload screenshot to S3', { + bucket: SCREENSHOT_BUCKET, + key, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const publicUrl = `https://${SCREENSHOT_PUBLIC_HOST}/${key}`; + const commentBody = renderCommentBody(publicUrl, previewUrl); + + try { + const result = await upsertTaskComment({ + repo, + issueOrPrNumber: pr.number, + body: commentBody, + token, + // Always POST fresh — a single PR can have multiple preview screenshots + // as the user pushes new commits, and editing the prior comment in + // place would lose the history. + existingCommentId: undefined, + }); + logger.info('Posted screenshot comment to PR', { + repo, + pr_number: pr.number, + comment_id: result.commentId, + public_url: publicUrl, + }); + } catch (err) { + logger.warn('Failed to post screenshot PR comment (non-fatal)', { + repo, + pr_number: pr.number, + error: err instanceof Error ? err.message : String(err), + }); + } + + // Best-effort Linear comment. The GitHub PR comment above is the + // load-bearing artifact; the Linear comment is bonus surface for + // reviewers who live in Linear. Only fires when the registry table + // is configured AND the PR title/body carries a Linear identifier. + if (LINEAR_WORKSPACE_REGISTRY_TABLE) { + const identifier = extractLinearIdentifier(pr.title) ?? extractLinearIdentifier(pr.body); + if (identifier) { + const linearIssue = await findLinearIssueByIdentifier(identifier, LINEAR_WORKSPACE_REGISTRY_TABLE); + if (linearIssue) { + const ok = await postIssueComment( + { + linearWorkspaceId: linearIssue.linearWorkspaceId, + registryTableName: LINEAR_WORKSPACE_REGISTRY_TABLE, + }, + linearIssue.issueId, + renderLinearCommentBody(publicUrl, previewUrl), + ); + if (ok) { + logger.info('Posted screenshot comment to Linear issue', { + identifier, + linear_issue_id: linearIssue.issueId, + workspace_slug: linearIssue.workspaceSlug, + }); + } else { + logger.warn('Failed to post screenshot Linear comment (non-fatal)', { + identifier, + linear_issue_id: linearIssue.issueId, + }); + } + } else { + logger.info('Linear identifier did not resolve to an issue — skipping Linear post', { + identifier, + repo, + pr_number: pr.number, + }); + } + } + } +} + +/** + * Open PR shape we extract from the GitHub commit-pulls API. Title + + * body are used downstream by the Linear issue lookup; the others go + * into log lines for debugging. + */ +interface OpenPr { + readonly number: number; + readonly title: string; + readonly body: string; +} + +/** + * Wait for an open PR to exist for the given SHA, retrying with a + * small backoff. Vercel commonly posts `deployment_status` before the + * agent's `gh pr create` call lands (we've measured 5-15s gap), so a + * single check would silently miss the common case. + * + * Schedule: 0s, 5s, 10s, 20s — covers the observed gap with one + * generous bonus retry. Total max wait ~35s. + */ +async function findPullRequestForShaWithRetry( + repo: string, + sha: string, + token: string, +): Promise { + const delays = [0, 5_000, 10_000, 20_000]; + for (const delay of delays) { + if (delay > 0) { + await new Promise((r) => setTimeout(r, delay)); + } + const pr = await findPullRequestForSha(repo, sha, token); + if (pr) return pr; + if (delay !== delays[delays.length - 1]) { + logger.info('Open PR not found yet for SHA — will retry', { repo, sha, next_delay_ms: delays[delays.indexOf(delay) + 1] }); + } + } + return null; +} + +/** + * Look up an open PR associated with `sha`. Uses the + * "List pull requests associated with a commit" GitHub API + * (https://docs.github.com/rest/commits/commits#list-pull-requests-associated-with-a-commit). + * + * Returns the first OPEN PR (with title/body), or null if none. + * Closed/merged PRs are filtered out — v1 only screenshots active + * reviews. + */ +async function findPullRequestForSha( + repo: string, + sha: string, + token: string, +): Promise { + const url = `https://api.github.com/repos/${repo}/commits/${sha}/pulls`; + let res: Response; + try { + res = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + } catch (err) { + logger.warn('GitHub commit-pulls fetch failed', { + repo, + sha, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (!res.ok) { + logger.warn('GitHub commit-pulls returned non-2xx', { + repo, + sha, + status: res.status, + }); + return null; + } + + const pulls = (await res.json()) as Array<{ + number?: number; + state?: string; + title?: string; + body?: string | null; + }>; + const open = pulls.find((p) => p.state === 'open' && typeof p.number === 'number'); + if (!open) return null; + return { + number: open.number!, + title: open.title ?? '', + body: open.body ?? '', + }; +} + +/** Build the S3 key for a screenshot. */ +function buildScreenshotKey(repo: string, sha: string, deploymentId: number | undefined): string { + const repoSlug = repo.replace('/', '_'); + const id = deploymentId !== undefined ? `-${deploymentId}` : ''; + return `screenshots/${repoSlug}/${sha}${id}.png`; +} + +/** Render the PR comment body. */ +function renderCommentBody(publicUrl: string, previewUrl: string): string { + return [ + '🖼️ **Preview screenshot**', + '', + `[![preview](${publicUrl})](${previewUrl})`, + '', + `_From [${previewUrl}](${previewUrl}) — captured automatically by ABCA after the deploy finished._`, + ].join('\n'); +} + +/** + * Linear comment body. Linear's markdown renders image embeds the + * same way GitHub does, but Linear collapses linked-image syntax — + * use the simpler `![alt](url)` form so it renders inline rather than + * as a clickable link with a tiny preview. + */ +function renderLinearCommentBody(publicUrl: string, previewUrl: string): string { + return [ + '🖼️ **Preview screenshot**', + '', + `![preview](${publicUrl})`, + '', + `Live preview: [${previewUrl}](${previewUrl})`, + '', + '_Captured automatically by ABCA after the Vercel preview deploy finished._', + ].join('\n'); +} diff --git a/cdk/src/handlers/github-webhook.ts b/cdk/src/handlers/github-webhook.ts new file mode 100644 index 00000000..bad8f345 --- /dev/null +++ b/cdk/src/handlers/github-webhook.ts @@ -0,0 +1,247 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { verifyGitHubRequest } from './shared/github-webhook-verify'; +import { logger } from './shared/logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const lambdaClient = new LambdaClient({}); + +const WEBHOOK_SECRET_ARN = process.env.GITHUB_WEBHOOK_SECRET_ARN!; +const DEDUP_TABLE_NAME = process.env.GITHUB_WEBHOOK_DEDUP_TABLE_NAME!; +const PROCESSOR_FUNCTION_NAME = process.env.GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NAME!; + +/** + * Dedup window. GitHub redelivers a webhook up to 5 times when our + * receiver returns 5xx (each retry ~ exponential backoff, max ~30s + * apart). 1h is generous coverage with slack for clock skew. + */ +const DEDUP_TTL_SECONDS = 60 * 60; + +/** + * Subset of GitHub's `deployment_status` payload we route on. Vercel + * (and any GitHub-Deployments-API-aware deploy backend) posts this when + * a preview / production deploy finishes. The interesting fields: + * - `deployment_status.state`: `success` | `failure` | `error` | `pending` | `in_progress` + * - `deployment_status.environment_url`: the deployed URL — lives on the + * *status* object, not the deployment itself. (The deployment object + * only has the immutable SHA + environment name; URL changes per + * status update — first `pending` has no URL, then `success` fills + * it in.) + * - `deployment.environment`: `Preview` | `Production` + * - `deployment.sha`: the commit SHA the deploy is for (used to map + * back to a PR via the GitHub commit-pulls API) + * + * Full payload is forwarded to the processor without re-serialization + * risk — the processor parses its own copy from the raw body. + */ +interface GitHubDeploymentStatusEnvelope { + readonly action?: string; + readonly deployment_status?: { + readonly id?: number; + readonly state?: string; + readonly environment_url?: string; + }; + readonly deployment?: { + readonly id?: number; + readonly sha?: string; + readonly environment?: string; + }; + readonly repository?: { + readonly full_name?: string; + }; +} + +/** + * POST /v1/github/webhook — GitHub webhook receiver. + * + * Verifies `X-Hub-Signature-256` (per + * https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries), + * filters to `deployment_status` events from Vercel-style preview deploys, + * dedups on `(repo, deployment_id, status_id)`, and async-invokes the + * processor Lambda so we can ack within GitHub's 10s timeout. Other event + * types (push, pull_request, ping, …) get an immediate 200 so GitHub + * doesn't retry them. + * + * Why `deployment_status` and not `workflow_run`: + * Vercel doesn't run a GitHub Action to deploy — it posts directly to + * the GitHub Deployments API. `deployment_status` carries the deploy + * URL (`deployment.environment_url`) and the SHA the deploy is for, + * letting us route to the correct ABCA task and screenshot the right + * URL without extra API calls. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return jsonResponse(400, { error: 'Request body is required' }); + } + + const signature = event.headers['X-Hub-Signature-256'] ?? event.headers['x-hub-signature-256'] ?? ''; + if (!signature) { + logger.warn('GitHub webhook missing X-Hub-Signature-256 header'); + return jsonResponse(401, { error: 'Missing signature' }); + } + + if (!await verifyGitHubRequest(WEBHOOK_SECRET_ARN, signature, event.body)) { + logger.warn('Invalid GitHub webhook signature'); + return jsonResponse(401, { error: 'Invalid signature' }); + } + + const eventType = event.headers['X-GitHub-Event'] ?? event.headers['x-github-event'] ?? ''; + + // GitHub fires `ping` once when the webhook is first registered. Ack with + // 200 so the GitHub UI shows the webhook as "delivered successfully" and + // operators don't think setup failed. + if (eventType === 'ping') { + return jsonResponse(200, { ok: true, ping: true }); + } + + // Anything other than deployment_status is silently 200'd. We'd rather + // drop unrelated events at the door than have them clutter the + // processor's invoke / log volume. + if (eventType !== 'deployment_status') { + logger.info('Ignoring non-deployment_status GitHub webhook', { event_type: eventType }); + return jsonResponse(200, { ok: true }); + } + + let payload: GitHubDeploymentStatusEnvelope; + try { + payload = JSON.parse(event.body) as GitHubDeploymentStatusEnvelope; + } catch (err) { + logger.warn('GitHub webhook body is not valid JSON', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(400, { error: 'Invalid JSON' }); + } + + // Vercel posts intermediate states (`pending`, `in_progress`) before + // the terminal `success` / `failure` / `error`. Only `success` deploys + // are worth screenshotting; everything else gets a clean 200 so GitHub + // doesn't retry. + if (payload.deployment_status?.state !== 'success') { + return jsonResponse(200, { ok: true, skipped_state: payload.deployment_status?.state }); + } + + // v1 scope: preview deploys only. Production deploys are skipped here + // (followup #87 in the plan covers post-merge screenshots if useful). + // Vercel labels its preview environment `Preview`; configurable via + // `SCREENSHOT_TARGET_ENVIRONMENT` env so non-Vercel backends with + // different naming can flip it without a code change. + const targetEnv = process.env.SCREENSHOT_TARGET_ENVIRONMENT ?? 'Preview'; + if (payload.deployment?.environment !== targetEnv) { + return jsonResponse(200, { + ok: true, + skipped_environment: payload.deployment?.environment, + }); + } + + const repo = payload.repository?.full_name; + const deploymentId = payload.deployment?.id; + const statusId = payload.deployment_status?.id; + if (!repo || !deploymentId || !statusId) { + logger.warn('GitHub deployment_status webhook missing repo, deployment id, or status id', { + repo, + deployment_id: deploymentId, + status_id: statusId, + }); + return jsonResponse(400, { error: 'Missing repo, deployment id, or status id' }); + } + + if (!payload.deployment_status?.environment_url) { + logger.warn('GitHub deployment_status webhook missing environment_url; cannot screenshot', { + repo, + deployment_id: deploymentId, + }); + return jsonResponse(200, { ok: true, skipped_no_url: true }); + } + + // Dedup on (repo, deployment_id, status_id). A single deploy lifecycle + // can emit multiple statuses; using the status id as the third leg + // keeps reruns of the same status (GitHub retries on 5xx) collapsed + // while distinct status transitions stay distinct. + const dedupKey = `${repo}#${deploymentId}#${statusId}`; + const nowSeconds = Math.floor(Date.now() / 1000); + try { + await ddb.send(new PutCommand({ + TableName: DEDUP_TABLE_NAME, + Item: { + dedup_key: dedupKey, + created_at: new Date().toISOString(), + ttl: nowSeconds + DEDUP_TTL_SECONDS, + }, + ConditionExpression: 'attribute_not_exists(dedup_key)', + })); + } catch (err) { + if (err instanceof ConditionalCheckFailedException) { + logger.info('GitHub webhook dedup hit — skipping reprocess', { + dedup_key: dedupKey, + }); + return jsonResponse(200, { ok: true, deduped: true }); + } + throw err; + } + + try { + await lambdaClient.send(new InvokeCommand({ + FunctionName: PROCESSOR_FUNCTION_NAME, + InvocationType: 'Event', + Payload: new TextEncoder().encode(JSON.stringify({ raw_body: event.body })), + })); + } catch (invokeErr) { + logger.error('Failed to invoke GitHub webhook processor', { + error: invokeErr instanceof Error ? invokeErr.message : String(invokeErr), + repo, + deployment_id: deploymentId, + status_id: statusId, + }); + // Roll the dedup row back so GitHub's retry can try dispatch again. + try { + await ddb.send(new DeleteCommand({ + TableName: DEDUP_TABLE_NAME, + Key: { dedup_key: dedupKey }, + })); + } catch (cleanupErr) { + logger.warn('Failed to roll back GitHub webhook dedup row after invoke failure', { + error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + dedup_key: dedupKey, + }); + } + return jsonResponse(500, { error: 'Dispatch failed' }); + } + + return jsonResponse(200, { ok: true }); + } catch (err) { + logger.error('GitHub webhook handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(500, { error: 'Internal server error' }); + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/linear-webhook-processor.ts b/cdk/src/handlers/linear-webhook-processor.ts index c028ef58..008e7b8f 100644 --- a/cdk/src/handlers/linear-webhook-processor.ts +++ b/cdk/src/handlers/linear-webhook-processor.ts @@ -30,7 +30,7 @@ const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); const PROJECT_MAPPING_TABLE = process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME!; const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!; const WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; -const DEFAULT_LABEL_FILTER = 'bgagent'; +const DEFAULT_LABEL_FILTER = 'abca'; /** * Post a Linear comment + ❌ reaction without ever propagating an error. @@ -149,6 +149,48 @@ export async function handler(event: ProcessorEvent): Promise { const issue = payload.data; const projectId = issue.projectId; + + // Resolve the per-project label override (if any) BEFORE the label gate so + // a workspace using a non-default label name still triggers correctly. The + // lookup runs on every Issue webhook (one extra GetItem vs. lookup-after- + // projectId-check), which is the price of having the silent label gate + // come first — see comment on the `shouldTrigger` block below. + let mappingItem: Record | undefined; + if (projectId) { + const mapping = await ddb.send(new GetCommand({ + TableName: PROJECT_MAPPING_TABLE, + Key: { linear_project_id: projectId }, + })); + if (mapping.Item && mapping.Item.status === 'active') { + mappingItem = mapping.Item; + } + } + const labelFilter = (mappingItem?.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER; + + // Silent kill-switch: an issue without the trigger label is not for us. + // This MUST run before any user-facing comment path. Previously the + // projectId-missing and not-onboarded paths ran first and posted + // "❌ project isn't onboarded" comments on every Issue event in every + // unmapped team — workspace webhooks fire workspace-wide, so a single + // un-onboarded team produced dozens of comments per issue change. + // Moving the label check first means an unlabeled issue is a true no-op: + // no comment, no reaction, no task creation, no DDB writes. + if (!shouldTrigger(payload, labelFilter)) { + logger.info('Linear webhook does not match trigger criteria — skipping silently', { + action: payload.action, + issue_id: issue.id, + label_filter: labelFilter, + has_project_mapping: Boolean(mappingItem), + current_labels: issue.labels?.map((l) => l?.name), + updated_from_keys: Object.keys(payload.updatedFrom ?? {}), + updated_from_label_ids: payload.updatedFrom?.labelIds, + current_label_ids: issue.labels?.map((l) => l?.id), + }); + return; + } + + // From here on the issue is labeled for ABCA, so user-facing failure + // comments are appropriate — the user explicitly asked for our attention. if (!projectId) { logger.info('Linear Issue has no projectId — skipping (cannot route to a repo)', { issue_id: issue.id, @@ -161,12 +203,7 @@ export async function handler(event: ProcessorEvent): Promise { return; } - // Look up project → repo mapping. - const mapping = await ddb.send(new GetCommand({ - TableName: PROJECT_MAPPING_TABLE, - Key: { linear_project_id: projectId }, - })); - if (!mapping.Item || mapping.Item.status !== 'active') { + if (!mappingItem) { logger.info('Linear project is not onboarded or is removed — skipping', { linear_project_id: projectId, issue_id: issue.id, @@ -178,24 +215,7 @@ export async function handler(event: ProcessorEvent): Promise { ); return; } - const repo = mapping.Item.repo as string; - const labelFilter = (mapping.Item.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER; - - // Only trigger when the configured label is present AND this event is a transition - // that meaningfully added/asserts the label — `create` with the label on it, or - // `update` that newly added it. - if (!shouldTrigger(payload, labelFilter)) { - logger.info('Linear webhook does not match trigger criteria', { - action: payload.action, - issue_id: issue.id, - label_filter: labelFilter, - current_labels: issue.labels?.map((l) => l?.name), - updated_from_keys: Object.keys(payload.updatedFrom ?? {}), - updated_from_label_ids: payload.updatedFrom?.labelIds, - current_label_ids: issue.labels?.map((l) => l?.id), - }); - return; - } + const repo = mappingItem.repo as string; // Resolve the actor → platform user. Fall back to creator if the actor is missing // (e.g. automation that set the label). If neither resolves, we cannot attribute diff --git a/cdk/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts new file mode 100644 index 00000000..a48c3545 --- /dev/null +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -0,0 +1,353 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Sha256 } from '@aws-crypto/sha256-js'; +import { + BedrockAgentCoreClient, + StartBrowserSessionCommand, + StopBrowserSessionCommand, +} from '@aws-sdk/client-bedrock-agentcore'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; +import WebSocket, { type RawData } from 'ws'; +import { logger } from './logger'; + +const REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'; + +/** + * AWS-managed default browser identifier. AgentCore Browser publishes a + * shared browser at this id without provisioning. (We could call + * `CreateBrowser` to get a dedicated one, but the screenshot path + * doesn't need any custom config — keep it simple.) + */ +const AWS_BROWSER_IDENTIFIER = 'aws.browser.v1'; + +/** + * Default budget for the entire screenshot job (start session → navigate + * → screenshot → stop). Lambda timeout should be at least 15s above this + * to leave headroom for the JSON encode + S3 PUT after the screenshot. + */ +const DEFAULT_TIMEOUT_MS = 60_000; + +/** CDP message id allocator. */ +let nextCdpId = 1; + +interface CdpMessage { + readonly id?: number; + readonly method?: string; + readonly params?: Record; + readonly sessionId?: string; + readonly result?: Record; + readonly error?: { code: number; message: string }; +} + +/** + * Capture a full-page PNG screenshot of `url` via AgentCore Browser. + * + * Implementation notes: + * - Uses the native `WebSocket` (Node 24+) and speaks Chrome DevTools + * Protocol directly. Avoids pulling in Playwright / puppeteer-core + * into the Lambda bundle (would be ~150 MB). + * - The automation WSS endpoint requires a SigV4-signed handshake + * request. Browser session creation is a normal SigV4 SDK call; + * once the session is created, the WSS upgrade GET also needs + * SigV4 headers in `Sec-WebSocket-*` companion form. Node's + * `WebSocket` constructor accepts a custom `Headers` object via + * the `protocols`/`headers` slot in `clientOptions`. + * - The flow is intentionally minimal: + * 1. StartBrowserSession (REST API; SDK call) + * 2. WS connect to the automation streamEndpoint (SigV4 handshake) + * 3. CDP `Target.attachToBrowserTarget` to get a flat session + * 4. CDP `Target.getTargets`, find the about:blank page + * 5. `Target.attachToTarget` (flatten=true) on that page → sessionId + * 6. `Page.navigate` + wait for `Page.frameStoppedLoading` + * 7. `Page.captureScreenshot` (returns base64 PNG) + * 8. StopBrowserSession (best-effort; sessions auto-expire) + * + * We don't try to be clever about fonts, viewports, or cookie + * injection — the agent is just snapshotting Vercel preview URLs that + * render with default settings. + * + * @param url The URL to navigate to and screenshot. + * @param opts.timeoutMs Override the default 60s budget. + * @returns Raw PNG bytes (NOT base64-wrapped) ready for S3.PutObject. + */ +export async function captureScreenshot(url: string, opts: { timeoutMs?: number } = {}): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const client = new BedrockAgentCoreClient({ region: REGION }); + + const startResp = await client.send(new StartBrowserSessionCommand({ + browserIdentifier: AWS_BROWSER_IDENTIFIER, + name: `bgagent-screenshot-${Date.now()}`, + })); + const sessionId = startResp.sessionId; + const automationEndpoint = startResp.streams?.automationStream?.streamEndpoint; + if (!sessionId || !automationEndpoint) { + throw new Error('AgentCore Browser StartBrowserSession returned no sessionId or automation endpoint'); + } + + logger.info('AgentCore Browser session started', { + session_id: sessionId, + automation_endpoint: automationEndpoint, + }); + + try { + const png = await runCdpScreenshot(automationEndpoint, url, timeoutMs); + return png; + } finally { + try { + await client.send(new StopBrowserSessionCommand({ + browserIdentifier: AWS_BROWSER_IDENTIFIER, + sessionId, + })); + } catch (err) { + // Sessions auto-expire after ~10 minutes if we leak — log and move on. + logger.warn('Failed to stop AgentCore Browser session (will auto-expire)', { + session_id: sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } +} + +/** + * Open the automation WebSocket, drive CDP, return PNG bytes. Caller is + * responsible for the StartBrowserSession + StopBrowserSession lifecycle. + */ +async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): Promise { + // AgentCore Browser's WSS endpoint accepts SigV4 in two forms: signed + // `Authorization` headers OR signed query parameters (presigned URL). + // We use the presigned-URL form because the `Host` header sent by the + // WS upgrade (handled inside `ws`) doesn't always match what we signed + // when using header-based auth, leading to 403s. Query-param signing + // sidesteps the Host-header reconciliation entirely. + const signedUrl = await sigV4PresignWss(wssUrl); + const ws = new WebSocket(signedUrl); + + const deadline = Date.now() + timeoutMs; + const remaining = () => Math.max(0, deadline - Date.now()); + + // Promise machinery for tracking in-flight CDP requests by `id`. + const pending = new Map void; reject: (err: Error) => void }>(); + const eventQueue: CdpMessage[] = []; + // Each waiter has a predicate; on each incoming event we deliver to the + // FIRST waiter whose predicate matches, otherwise queue the event. + interface EventWaiter { + readonly predicate: (msg: CdpMessage) => boolean; + readonly resolve: (msg: CdpMessage) => void; + } + const eventWaiters: EventWaiter[] = []; + + ws.on('message', (raw: RawData) => { + const data = raw.toString(); + let msg: CdpMessage; + try { + msg = JSON.parse(data) as CdpMessage; + } catch { + return; + } + if (typeof msg.id === 'number') { + const slot = pending.get(msg.id); + if (slot) { + pending.delete(msg.id); + if (msg.error) { + slot.reject(new Error(`CDP error ${msg.error.code}: ${msg.error.message}`)); + } else { + slot.resolve(msg); + } + } + } else if (msg.method) { + const waiterIdx = eventWaiters.findIndex((w) => w.predicate(msg)); + if (waiterIdx !== -1) { + const [waiter] = eventWaiters.splice(waiterIdx, 1); + waiter.resolve(msg); + } else { + eventQueue.push(msg); + } + } + }); + + // Open the socket. `ws` exposes node-style EventEmitter; the + // `unexpected-response` event surfaces HTTP-level handshake failures + // (e.g. 403 from misaligned SigV4) so we can log a meaningful error + // instead of an empty `error` event. + await new Promise((resolve, reject) => { + const onOpen = (): void => { + cleanup(); + resolve(); + }; + const onError = (err: Error): void => { + cleanup(); + reject(new Error(`AgentCore Browser WebSocket error: ${err.message || '(no message)'}`)); + }; + const onUnexpectedResponse = (_req: unknown, res: { statusCode?: number }): void => { + cleanup(); + reject(new Error(`AgentCore Browser WebSocket handshake failed: HTTP ${res.statusCode ?? '?'}`)); + }; + const cleanup = (): void => { + ws.removeListener('open', onOpen); + ws.removeListener('error', onError); + ws.removeListener('unexpected-response', onUnexpectedResponse); + }; + ws.on('open', onOpen); + ws.on('error', onError); + ws.on('unexpected-response', onUnexpectedResponse); + setTimeout(() => { + cleanup(); + reject(new Error(`AgentCore Browser WebSocket open timeout after ${timeoutMs}ms`)); + }, remaining()); + }); + + function cdpSend(method: string, params: Record = {}, sessionId?: string): Promise { + const id = nextCdpId++; + const message: CdpMessage = { id, method, params, ...(sessionId ? { sessionId } : {}) }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`CDP ${method} timed out after ${remaining()}ms`)); + }, remaining()); + pending.set(id, { + resolve: (msg) => { clearTimeout(timer); resolve(msg); }, + reject: (err) => { clearTimeout(timer); reject(err); }, + }); + ws.send(JSON.stringify(message)); + }); + } + + function waitForEvent(method: string): Promise { + const queued = eventQueue.findIndex((m) => m.method === method); + if (queued !== -1) { + const [match] = eventQueue.splice(queued, 1); + return Promise.resolve(match); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = eventWaiters.findIndex((w) => w.resolve === wrappedResolve); + if (idx !== -1) eventWaiters.splice(idx, 1); + reject(new Error(`Timed out waiting for CDP event ${method}`)); + }, remaining()); + const wrappedResolve = (msg: CdpMessage): void => { + clearTimeout(timer); + resolve(msg); + }; + eventWaiters.push({ + predicate: (msg) => msg.method === method, + resolve: wrappedResolve, + }); + }); + } + + try { + // 1. List existing targets, find the default about:blank page. + const targetsResp = await cdpSend('Target.getTargets'); + const targets = (targetsResp.result?.targetInfos as Array<{ targetId: string; type: string; url: string }> | undefined) ?? []; + const pageTarget = targets.find((t) => t.type === 'page'); + if (!pageTarget) { + throw new Error('No page target found in AgentCore Browser session'); + } + + // 2. Attach with flatten=true to get a sessionId we can route subsequent commands to. + const attachResp = await cdpSend('Target.attachToTarget', { + targetId: pageTarget.targetId, + flatten: true, + }); + const pageSessionId = attachResp.result?.sessionId as string | undefined; + if (!pageSessionId) { + throw new Error('Target.attachToTarget did not return a sessionId'); + } + + // 3. Enable Page domain so we get frameStoppedLoading events. + await cdpSend('Page.enable', {}, pageSessionId); + + // 4. Navigate. The response includes a `frameId`; we wait on the + // `Page.loadEventFired` event below (more reliable than + // `frameStoppedLoading` which can fire before navigation + // actually starts on `about:blank` → real-URL transitions). + const navResp = await cdpSend('Page.navigate', { url }, pageSessionId); + const navError = navResp.result?.errorText as string | undefined; + if (navError) { + throw new Error(`Page.navigate failed: ${navError}`); + } + + // 5. Wait for the page load event. SPA-style apps may continue + // fetching after this fires, so add a 2s settle wait. For + // Vercel preview URLs this tends to be enough. + await waitForEvent('Page.loadEventFired'); + await new Promise((r) => setTimeout(r, 2000)); + + // 6. Take the screenshot. + const shotResp = await cdpSend('Page.captureScreenshot', { + format: 'png', + captureBeyondViewport: true, + }, pageSessionId); + const base64 = shotResp.result?.data as string | undefined; + if (!base64) { + throw new Error('Page.captureScreenshot returned no data'); + } + return Buffer.from(base64, 'base64'); + } finally { + try { ws.close(); } catch { /* ignore */ } + } +} + +/** + * Presign the WSS URL with SigV4 query parameters. AgentCore Browser + * accepts auth either as headers on the upgrade GET or as query params + * on the URL itself; the latter is more robust through WebSocket + * clients that rewrite Host headers (e.g. `ws`). + * + * Returns a `wss://...?X-Amz-Algorithm=...&X-Amz-Credential=...&...` + * URL ready to pass straight to `new WebSocket(...)`. + */ +async function sigV4PresignWss(wssUrl: string): Promise { + const u = new URL(wssUrl); + const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region: REGION, + credentials: defaultProvider(), + sha256: Sha256, + applyChecksum: false, + }); + + // Convert wss:// → https:// for the signing request (SigV4 doesn't + // know about wss). The signature is over the path + query, so the + // protocol on the signed request is irrelevant — we paste the auth + // params back onto the original wss:// URL. + const queryEntries = Array.from(u.searchParams.entries()); + const query: Record = {}; + for (const [k, v] of queryEntries) query[k] = v; + + const req = new HttpRequest({ + method: 'GET', + protocol: 'https:', + hostname: u.hostname, + path: u.pathname, + query, + headers: { host: u.hostname }, + }); + + // 60s expiry is fine — we open the socket immediately after signing. + const presigned = await signer.presign(req, { expiresIn: 60 }); + const out = new URL(wssUrl); + for (const [k, v] of Object.entries(presigned.query ?? {})) { + out.searchParams.set(k, Array.isArray(v) ? v[0] : (v as string)); + } + return out.toString(); +} diff --git a/cdk/src/handlers/shared/github-webhook-verify.ts b/cdk/src/handlers/shared/github-webhook-verify.ts new file mode 100644 index 00000000..1023686d --- /dev/null +++ b/cdk/src/handlers/shared/github-webhook-verify.ts @@ -0,0 +1,127 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { logger } from './logger'; + +const sm = new SecretsManagerClient({}); + +/** + * In-memory secret cache (5-minute TTL). Same pattern as `linear-verify.ts` + * — webhook secrets rotate infrequently, and skipping a Secrets Manager + * round-trip on every webhook keeps the receiver well under GitHub's 10s + * timeout. After rotation, the verifier transparently re-fetches once. + */ +const secretCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Fetch a GitHub webhook secret from Secrets Manager with caching. + * @param secretId - the Secrets Manager secret ID or ARN. + * @param forceRefresh - bypass cache and re-fetch. + * @returns the secret string, or null if not found. + */ +export async function getGitHubWebhookSecret(secretId: string, forceRefresh = false): Promise { + const now = Date.now(); + if (!forceRefresh) { + const cached = secretCache.get(secretId); + if (cached && cached.expiresAt > now) { + return cached.secret; + } + } + + try { + const result = await sm.send(new GetSecretValueCommand({ SecretId: secretId })); + if (!result.SecretString) { + secretCache.delete(secretId); + return null; + } + secretCache.set(secretId, { secret: result.SecretString, expiresAt: now + CACHE_TTL_MS }); + return result.SecretString; + } catch (err) { + const errorName = (err as Error)?.name; + if (errorName === 'ResourceNotFoundException') { + logger.error('GitHub webhook secret not found', { secret_id: secretId }); + secretCache.delete(secretId); + return null; + } + logger.error('Failed to fetch GitHub webhook secret', { + secret_id: secretId, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +/** Drop a cached webhook secret — used on suspected rotation. */ +export function invalidateGitHubWebhookSecretCache(secretId: string): void { + secretCache.delete(secretId); +} + +/** + * Verify a GitHub webhook signature. + * + * GitHub signs with HMAC-SHA256 over the raw body, hex-encoded, prefixed + * with the literal `sha256=` and delivered in the `X-Hub-Signature-256` + * header (per + * https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries). + * The legacy `X-Hub-Signature` (SHA1) header is not validated — GitHub + * always sends both, but SHA256 is the secure one. + * + * @param webhookSecret - the per-webhook signing secret. + * @param header - the `X-Hub-Signature-256` header value (with `sha256=` prefix). + * @param body - the raw request body string. + * @returns true if the signature matches. + */ +export function verifyGitHubSignature(webhookSecret: string, header: string, body: string): boolean { + if (!header.startsWith('sha256=')) { + return false; + } + const provided = header.slice('sha256='.length); + const expected = crypto.createHmac('sha256', webhookSecret).update(body).digest('hex'); + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided)); + } catch (err) { + logger.warn('GitHub signature comparison failed', { + error: err instanceof Error ? err.message : String(err), + expected_length: expected.length, + provided_length: provided.length, + }); + return false; + } +} + +/** + * Verify a GitHub webhook request, with one transparent re-fetch on + * cache miss. Same UX as `verifyLinearRequest` so warm Lambdas don't + * silently reject post-rotation deliveries for up to 5 minutes. + */ +export async function verifyGitHubRequest(secretId: string, header: string, body: string): Promise { + const cached = await getGitHubWebhookSecret(secretId); + if (cached && verifyGitHubSignature(cached, header, body)) { + return true; + } + + invalidateGitHubWebhookSecretCache(secretId); + const fresh = await getGitHubWebhookSecret(secretId, true); + if (!fresh) return false; + if (fresh === cached) return false; + return verifyGitHubSignature(fresh, header, body); +} diff --git a/cdk/src/handlers/shared/linear-issue-lookup.ts b/cdk/src/handlers/shared/linear-issue-lookup.ts new file mode 100644 index 00000000..4ce4a6bd --- /dev/null +++ b/cdk/src/handlers/shared/linear-issue-lookup.ts @@ -0,0 +1,187 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { resolveLinearOauthToken } from './linear-oauth-resolver'; +import { logger } from './logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +/** + * Linear issue identifier shape, e.g. `ABCA-42`. Linear identifiers are + * `-` where the key is uppercase letters and digits is + * a positive integer. We bound the team key length [1,10] and number + * length [1,8] to avoid pathological inputs. + */ +const LINEAR_IDENTIFIER_RE = /\b([A-Z][A-Z0-9]{0,9})-(\d{1,8})\b/g; + +/** + * Pull the first Linear issue identifier (e.g. `ABCA-42`) found in + * the given text. PR titles and bodies typically include this either + * because the agent's task_description carries the identifier, or + * because Linear's own GitHub integration auto-injects an + * `ABCA-42 ` reference. + * + * Returns the first match in document order. If multiple distinct + * identifiers are present we still return the first — multi-issue PRs + * are unusual enough that single-screenshot-per-issue is acceptable. + */ +export function extractLinearIdentifier(text: string | null | undefined): string | null { + if (!text) return null; + const match = LINEAR_IDENTIFIER_RE.exec(text); + // The regex has the `g` flag for testability; reset lastIndex so + // back-to-back calls behave correctly. + LINEAR_IDENTIFIER_RE.lastIndex = 0; + return match ? `${match[1]}-${match[2]}` : null; +} + +/** + * Resolved Linear issue location, paired with the workspace that owns + * it. The screenshot processor uses these to construct a + * LinearFeedbackContext + issueId for postIssueComment. + */ +export interface LinearIssueLocation { + readonly issueId: string; + readonly linearWorkspaceId: string; + readonly workspaceSlug: string; +} + +const ISSUE_BY_IDENTIFIER_QUERY = ` +query IssueByIdentifier($identifier: String!) { + issueVcsBranchSearch(branchName: $identifier) { + id + identifier + } +} +`.trim(); + +/** + * Look up a Linear issue by identifier (e.g. `ABCA-42`) by iterating + * over every active workspace in the registry until one returns a + * match. Returns the first hit. + * + * For v1 this scan is cheap — typical deployments have 1-2 workspaces. + * If a stack ever onboards many workspaces sharing identifier prefixes, + * a followup can store team_key prefixes on the registry row and route + * directly. Until then, linear-time iteration is fine. + * + * @param identifier `ABCA-42`-style Linear issue identifier + * @param registryTableName name of LinearWorkspaceRegistryTable + * @returns issue location, or null if no workspace contains the issue + */ +export async function findLinearIssueByIdentifier( + identifier: string, + registryTableName: string, +): Promise { + let active: Array<{ linear_workspace_id: string; workspace_slug: string }> = []; + try { + const scanResp = await ddb.send(new ScanCommand({ + TableName: registryTableName, + FilterExpression: '#s = :active', + ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { ':active': 'active' }, + })); + active = (scanResp.Items ?? []).map((item) => ({ + linear_workspace_id: item.linear_workspace_id as string, + workspace_slug: item.workspace_slug as string, + })); + } catch (err) { + logger.warn('Linear issue lookup: failed to scan workspace registry', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (active.length === 0) { + logger.info('Linear issue lookup: no active workspaces in registry', { identifier }); + return null; + } + + for (const ws of active) { + const resolved = await resolveLinearOauthToken(ws.linear_workspace_id, registryTableName); + if (!resolved) continue; + + const found = await queryIssueByIdentifier(resolved.accessToken, identifier); + if (found) { + return { + issueId: found, + linearWorkspaceId: ws.linear_workspace_id, + workspaceSlug: ws.workspace_slug, + }; + } + } + return null; +} + +/** + * Issue the GraphQL query to Linear; return the issue UUID on hit, null + * on miss. Never throws — caller iterates onto the next workspace. + * + * Uses `issueVcsBranchSearch` because it accepts the human-readable + * identifier directly (the regular `issue(id:)` query needs a UUID, + * which we don't have yet). The branch-search API was designed for + * exactly this — VCS integrations resolving `-` strings to + * issue rows. + */ +async function queryIssueByIdentifier(accessToken: string, identifier: string): Promise { + let resp: Response; + try { + resp = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: ISSUE_BY_IDENTIFIER_QUERY, + variables: { identifier }, + }), + }); + } catch (err) { + logger.warn('Linear issue lookup: graphql request failed', { + identifier, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (!resp.ok) { + logger.warn('Linear issue lookup: graphql non-2xx', { identifier, status: resp.status }); + return null; + } + + const body = (await resp.json()) as { + data?: { issueVcsBranchSearch?: { id?: string; identifier?: string } | null }; + errors?: unknown; + }; + if (body.errors) { + logger.warn('Linear issue lookup: graphql errors', { identifier, errors: body.errors }); + return null; + } + const hit = body.data?.issueVcsBranchSearch; + if (!hit?.id) return null; + // Sanity: the response identifier must match what we asked for. + // `issueVcsBranchSearch` is a fuzzy match against branch-name patterns; + // exact-match the identifier to avoid linking to a near-neighbor issue. + if (hit.identifier && hit.identifier.toUpperCase() !== identifier.toUpperCase()) { + return null; + } + return hit.id; +} diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 0d02c77e..97a6b06b 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -39,6 +39,7 @@ import { CedarWasmLayer } from '../constructs/cedar-wasm-layer'; import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; import { FanOutConsumer } from '../constructs/fanout-consumer'; +import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-integration'; import { LinearIntegration } from '../constructs/linear-integration'; import { RepoTable } from '../constructs/repo-table'; import { SlackIntegration } from '../constructs/slack-integration'; @@ -765,6 +766,43 @@ export class AgentStack extends Stack { description: 'Name of the DynamoDB Linear workspace registry — `bgagent linear setup` writes a row per OAuth-installed workspace', }); + // --- GitHub deployment-status → screenshot pipeline --- + // Listens for Vercel-style preview deploys, screenshots the + // `deployment.environment_url` via AgentCore Browser, posts the + // image into a fresh PR comment. Default-on: any repo whose + // GitHub webhook is configured will get screenshotted on + // successful preview deploys; no opt-in flag. + const githubScreenshot = new GitHubScreenshotIntegration(this, 'GitHubScreenshotIntegration', { + api: taskApi.api, + githubTokenSecret, + // When the screenshot lands on a PR linked to a Linear issue + // (identifier in the PR title/body), also post the screenshot + // as a comment on that Linear issue. Wired through the existing + // workspace registry so token resolution reuses the per-workspace + // OAuth secrets created by `bgagent linear setup`. + linearWorkspaceRegistryTable: linearIntegration.workspaceRegistryTable, + }); + + new CfnOutput(this, 'GitHubWebhookUrl', { + value: `${taskApi.api.url}github/webhook`, + description: 'URL to configure as the GitHub webhook target on demo repos (deployment_status events)', + }); + + new CfnOutput(this, 'GitHubWebhookSecretArn', { + value: githubScreenshot.webhookSecret.secretArn, + description: 'Secrets Manager ARN for the GitHub webhook signing secret — paste GitHub\'s value here after configuring the webhook', + }); + + new CfnOutput(this, 'ScreenshotBucketName', { + value: githubScreenshot.screenshotBucket.bucket.bucketName, + description: 'Private S3 bucket hosting Vercel-preview screenshots (served via CloudFront)', + }); + + new CfnOutput(this, 'ScreenshotCloudFrontDomain', { + value: githubScreenshot.screenshotBucket.distribution.domainName, + description: 'CloudFront domain that serves the screenshot bucket anonymously to GitHub PR / Linear renders', + }); + // --- Bedrock model invocation logging (account-level) --- const invocationLogGroup = new logs.LogGroup(this, 'ModelInvocationLogGroup', { logGroupName: `/aws/bedrock/model-invocation-logs/${this.stackName}`, diff --git a/cdk/test/handlers/linear-webhook-processor.test.ts b/cdk/test/handlers/linear-webhook-processor.test.ts index 80d2fb61..d19e0a80 100644 --- a/cdk/test/handlers/linear-webhook-processor.test.ts +++ b/cdk/test/handlers/linear-webhook-processor.test.ts @@ -62,7 +62,7 @@ function issue(overrides: Record = {}): Record description: 'Users cannot log in.', projectId: 'project-1', teamId: 'team-1', - labels: [{ id: 'lbl-bg', name: 'bgagent' }], + labels: [{ id: 'lbl-abca', name: 'abca' }], }, ...overrides, }; @@ -134,7 +134,7 @@ describe('linear-webhook-processor handler', () => { ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); const payload = issue({ action: 'update', - updatedFrom: { labelIds: ['lbl-bg', 'lbl-other'] }, + updatedFrom: { labelIds: ['lbl-abca', 'lbl-other'] }, }); await handler(eventWith(payload)); expect(createTaskCoreMock).not.toHaveBeenCalled(); @@ -150,7 +150,7 @@ describe('linear-webhook-processor handler', () => { test('creates task with channel_source=linear and linear_* metadata', async () => { ddbSend - .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active', label_filter: 'bgagent' } }) + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) .mockResolvedValueOnce({ Item: { linear_identity: 'org-1#user-1', @@ -347,6 +347,36 @@ describe('linear-webhook-processor handler', () => { expect(reportIssueFailureMock).not.toHaveBeenCalled(); }); + test('unlabeled issue in a NON-onboarded project is a silent no-op (regression: comment-spam)', async () => { + // Workspace webhooks fire workspace-wide — issues in teams that ABCA + // was never onboarded into still reach this Lambda. Previously, every + // such event posted a "❌ project isn't onboarded" comment, producing + // 47 identical comments in 5min on a single GRO issue. The label gate + // now runs FIRST, so an unlabeled issue produces zero side effects no + // matter what state the project mapping is in. + ddbSend.mockResolvedValueOnce({ Item: undefined }); + const payload = issue(); + (payload.data as Record).labels = [{ id: 'l2', name: 'other' }]; + + await handler(eventWith(payload)); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('unlabeled issue with no projectId is a silent no-op', async () => { + const payload = issue(); + const data = { ...(payload.data as Record) }; + delete data.projectId; + data.labels = [{ id: 'l2', name: 'other' }]; + payload.data = data; + + await handler(eventWith(payload)); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + test('safeReportIssueFailure: synchronous throw from reportIssueFailure does not propagate', async () => { // Defends against a future signature refactor that breaks the helper's // never-throw contract. Today `Promise.allSettled` guarantees this; if diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index bec1ef15..859fb630 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -36,13 +36,14 @@ describe('AgentStack', () => { expect(template).toBeDefined(); }); - test('creates exactly 13 DynamoDB tables', () => { + test('creates exactly 14 DynamoDB tables', () => { // task, task-events, repo, user-concurrency, webhook, task-nudges, // task-approvals (Cedar HITL V2), // slack-installation, slack-user-mapping, // linear-project-mapping, linear-user-mapping, linear-webhook-dedup, - // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping) - template.resourceCountIs('AWS::DynamoDB::Table', 13); + // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping), + // github-webhook-dedup (added by GitHubScreenshotIntegration) + template.resourceCountIs('AWS::DynamoDB::Table', 14); }); test('creates TaskApprovalsTable with user_id-status-index GSI', () => { diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 0975ad8f..a04eb4e2 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -24,6 +24,7 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { CreateSecretCommand, GetSecretValueCommand, + ListSecretsCommand, PutSecretValueCommand, ResourceExistsException, SecretsManagerClient, @@ -39,6 +40,7 @@ import { computeExpiresAt, exchangeAuthorizationCode, generatePkce, + LINEAR_OAUTH_SECRET_PREFIX, linearOauthSecretName, StoredLinearOauthToken, } from '../linear-oauth'; @@ -617,67 +619,108 @@ export function makeLinearCommand(): Command { linear.addCommand( new Command('list-projects') - .description('List Linear projects visible to the stored API token (with full UUIDs)') + .description('List Linear projects visible to the OAuth-installed workspace (with full UUIDs)') .option('--region ', 'AWS region (defaults to configured region)') - .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') + .option('--slug ', 'Linear workspace slug (urlKey). If omitted, queries every active workspace in the registry.') .option('--output ', 'Output format (text or json)', 'text') .action(async (opts) => { const config = loadConfig(); const region = opts.region || config.region; + const sm = new SecretsManagerClient({ region }); - const apiTokenSecretArn = await getStackOutput(region, opts.stackName, 'LinearApiTokenSecretArn'); - if (!apiTokenSecretArn) { - console.error('Could not find LinearApiTokenSecretArn in stack outputs. Deploy the stack first.'); - process.exit(1); + // Resolve the set of workspace slugs to query. Either an + // explicit `--slug` (one workspace) or every Linear workspace + // we have an OAuth secret for (list every `bgagent-linear-oauth-*`). + let slugs: string[]; + if (opts.slug) { + slugs = [opts.slug]; + } else { + const listed = await sm.send(new ListSecretsCommand({ + Filters: [{ Key: 'name', Values: [LINEAR_OAUTH_SECRET_PREFIX] }], + })); + slugs = (listed.SecretList ?? []) + .map((s) => s.Name ?? '') + .filter((n) => n.startsWith(LINEAR_OAUTH_SECRET_PREFIX)) + .map((n) => n.slice(LINEAR_OAUTH_SECRET_PREFIX.length)); + if (slugs.length === 0) { + console.error('No Linear OAuth installs found. Run `bgagent linear setup ` first.'); + process.exit(1); + } } - const sm = new SecretsManagerClient({ region }); - const secret = await sm.send(new GetSecretValueCommand({ SecretId: apiTokenSecretArn })); - const apiToken = secret.SecretString; - if (!apiToken || apiToken === ' ') { - console.error('Linear API token is not populated. Run `bgagent linear setup` first.'); - process.exit(1); - } + type ProjectRow = { + slug: string; + id: string; + name: string; + team?: string; + }; + const rows: ProjectRow[] = []; + + for (const slug of slugs) { + const secretName = linearOauthSecretName(slug); + let accessToken: string; + try { + const resp = await sm.send(new GetSecretValueCommand({ SecretId: secretName })); + const stored = JSON.parse(resp.SecretString ?? '{}') as { access_token?: string }; + if (!stored.access_token) { + console.error(`Secret ${secretName} is missing access_token; skipping.`); + continue; + } + accessToken = stored.access_token; + } catch (err) { + console.error(`Failed to read ${secretName}: ${err instanceof Error ? err.message : String(err)}`); + continue; + } - let projects: Array<{ id: string; name: string; teams?: { nodes?: Array<{ id: string; name: string }> } }>; - try { - const res = await fetch('https://api.linear.app/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': apiToken, - }, - body: JSON.stringify({ - query: '{ projects { nodes { id name teams { nodes { id name } } } } }', - }), - }); - if (!res.ok) { - throw new Error(`Linear API returned ${res.status}`); + try { + const res = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + query: '{ projects(first: 100) { nodes { id name teams(first: 1) { nodes { name } } } } }', + }), + }); + if (!res.ok) { + console.error(`Linear API returned ${res.status} for workspace '${slug}'`); + continue; + } + const body = await res.json() as { + data?: { projects?: { nodes?: Array<{ id: string; name: string; teams?: { nodes?: Array<{ name: string }> } }> } }; + }; + for (const p of body.data?.projects?.nodes ?? []) { + rows.push({ + slug, + id: p.id, + name: p.name, + team: p.teams?.nodes?.[0]?.name, + }); + } + } catch (err) { + console.error(`Failed to fetch projects for '${slug}': ${err instanceof Error ? err.message : String(err)}`); + continue; } - const body = await res.json() as { data?: { projects?: { nodes?: typeof projects } } }; - projects = body.data?.projects?.nodes ?? []; - } catch (err) { - console.error(`Failed to fetch Linear projects: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); } if (opts.output === 'json') { - console.log(formatJson(projects)); + console.log(formatJson(rows)); return; } - if (projects.length === 0) { - console.log('No Linear projects visible to the stored API token.'); + if (rows.length === 0) { + console.log('No Linear projects visible to any installed workspace.'); return; } - console.log(`Found ${projects.length} Linear project(s):\n`); - for (const p of projects) { - const team = p.teams?.nodes?.[0]; - console.log(` ${p.name}`); - console.log(` id: ${p.id}`); - if (team) { - console.log(` team: ${team.name} (${team.id})`); + console.log(`Found ${rows.length} Linear project(s):\n`); + for (const r of rows) { + console.log(` ${r.name}`); + console.log(` id: ${r.id}`); + console.log(` workspace: ${r.slug}`); + if (r.team) { + console.log(` team: ${r.team}`); } console.log(''); } diff --git a/cli/src/linear-oauth.ts b/cli/src/linear-oauth.ts index c2ce2902..d23e390d 100644 --- a/cli/src/linear-oauth.ts +++ b/cli/src/linear-oauth.ts @@ -88,13 +88,21 @@ export interface StoredLinearOauthToken { readonly installed_by_platform_user_id: string; } +/** + * Common prefix for all per-workspace Linear OAuth secrets. The full + * secret name is `${LINEAR_OAUTH_SECRET_PREFIX}`. Use this when + * scanning Secrets Manager for every workspace install (e.g. the CLI's + * `list-projects` command queries every workspace it can find). + */ +export const LINEAR_OAUTH_SECRET_PREFIX = 'bgagent-linear-oauth-'; + /** * Build the secret name for a given Linear workspace slug. Matches the * naming convention encoded in the runtime's IAM policy resource pattern, * so changes here MUST be matched by the IAM resource pattern in CDK. */ export function linearOauthSecretName(workspaceSlug: string): string { - return `bgagent-linear-oauth-${workspaceSlug}`; + return `${LINEAR_OAUTH_SECRET_PREFIX}${workspaceSlug}`; } /** diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 4bbe89e4..a15c39dc 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -56,6 +56,7 @@ export default defineConfig({ { slug: 'using/webhook-integration' }, { slug: 'using/slack-setup-guide' }, { slug: 'using/linear-setup-guide' }, + { slug: 'using/vercel-setup-guide' }, { slug: 'using/task-lifecycle' }, { slug: 'using/what-the-agent-does' }, { slug: 'using/tips-for-being-a-good-citizen' }, diff --git a/docs/guides/VERCEL_SETUP_GUIDE.md b/docs/guides/VERCEL_SETUP_GUIDE.md new file mode 100644 index 00000000..d78d807a --- /dev/null +++ b/docs/guides/VERCEL_SETUP_GUIDE.md @@ -0,0 +1,220 @@ +# Vercel preview screenshots setup guide + +This guide walks through wiring a Vercel-connected GitHub repo into ABCA so that every preview deploy gets screenshotted and posted as a comment on both the open GitHub PR **and** the linked Linear issue. + +> **Prerequisite phases:** Linear OAuth (Phase 2.0b — see [Linear setup guide](./LINEAR_SETUP_GUIDE.md)) must be installed before this guide is useful, since the screenshot-to-Linear leg reuses the per-workspace OAuth tokens from that path. + +## What you get + +When ABCA opens a PR for a Linear-driven task, Vercel deploys the preview, posts a `deployment_status` event back to GitHub, and ABCA's webhook receiver: + +1. Captures a full-page screenshot of the preview URL via AgentCore Browser +2. Uploads the PNG to a private S3 bucket served via CloudFront +3. Posts a markdown image comment on the open GitHub PR +4. Looks up the Linear issue (by identifier in the PR title/body — e.g. `ABCA-42`) and posts the same screenshot as a Linear comment + +End-to-end latency: typically 10–15 seconds after Vercel reports the deploy. + +## How it works + +``` +agent push → Vercel preview build → deployment_status webhook + ↓ + POST /v1/github/webhook + ↓ + receiver Lambda (HMAC verify, dedup) + ↓ + processor Lambda + ↓ + AgentCore Browser session + ↓ + PNG → private S3 (30-day TTL) + ↓ + CloudFront-served public URL + ↓ + GitHub PR comment + Linear issue comment +``` + +Architecture notes: + +- **Lambda-only.** No agent runtime is involved post-PR — the screenshot job is deterministic; an LLM would only add cost without changing behavior. +- **AWS-managed default browser.** AgentCore Browser ships an `aws.browser.v1` session you can attach to without provisioning your own browser resource. +- **Private S3 + CloudFront with OAC.** Screenshot bucket is fully private; CloudFront serves images anonymously over HTTPS so GitHub Markdown and Linear's image previews can render them without auth. +- **WAF exemption.** The `/v1/github/webhook` path is excluded from the AWSManagedRulesCommonRuleSet because Vercel `deployment_status` payloads (which embed absolute deploy URLs) trip `GenericRFI_BODY` otherwise. + +## Prerequisites + +- ABCA stack deployed (`mise //cdk:deploy` in this branch or later) — confirm `GitHubWebhookUrl` + `GitHubWebhookSecretArn` + `ScreenshotCloudFrontDomain` are listed in the stack outputs +- Linear OAuth installed for at least one workspace (`bgagent linear setup `) +- A GitHub repo you own AND where you can install the Vercel app +- A Vercel account that can import that repo +- AWS CLI logged in to the same account as the ABCA stack +- The `bgagent` CLI installed (`bgagent configure`, `bgagent login`) + +## Step-by-step setup + +### Step 1 — Connect Vercel to your GitHub repo + +1. Open https://vercel.com/dashboard. +2. **Add New** → **Project**. +3. Find your repo in the list (e.g. `your-org/vercel-abca-linear`). If it's not visible, click "Adjust GitHub App Permissions" and grant access. +4. Click **Import**. +5. Accept the framework defaults — Vercel auto-detects most stacks. +6. Click **Deploy**. Wait for the first deploy to finish. + +### Step 2 — Vercel project settings + +Go to **your-project → Settings** in the Vercel dashboard. + +#### Settings → Git +- **Connected Git Repository**: confirm the repo is listed. +- **`deployment_status` Events**: toggle **Enabled** (this is what tells Vercel to post the webhook to GitHub when each deploy finishes). +- **Pull Request Comments**: optional — Vercel's own comment with the preview URL. Doesn't affect ABCA either way. + +#### Settings → Deployment Protection +- **Vercel Authentication**: set to **Disabled** (or "Only Production Deployments") for the demo. Otherwise AgentCore Browser will hit a Vercel auth wall and screenshot the login page instead of your app. + +> **Production hardening.** When you graduate the demo to a real production setup, switch Vercel Authentication back to **Standard Protection** and configure a [signed bypass token](https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection#protection-bypass-for-automation). The screenshot processor will need to inject the bypass token as a query parameter on the preview URL — this is tracked as a followup. + +### Step 3 — Onboard the repo to ABCA + +ABCA needs to know the repo is allowed to receive tasks. Two writes: + +#### 3a. Register the repo in `RepoTable` + +There's no CLI helper today; do a direct DDB put. Replace the table name with your stack's value (`aws cloudformation describe-stacks ... RepoTableName`): + +```bash +aws dynamodb put-item --region us-east-1 \ + --table-name \ + --item '{ + "repo": {"S": "your-org/your-vercel-repo"}, + "status": {"S": "active"}, + "onboarded_at": {"S": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}, + "updated_at": {"S": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"} + }' +``` + +#### 3b. Map a Linear project → this repo + +```bash +# Find the Linear project UUID +bgagent linear list-projects + +# Map it to the repo +bgagent linear onboard-project \ + --repo your-org/your-vercel-repo \ + --label abca +``` + +The `--label` controls which Linear label triggers a task. Defaults to `bgagent`; the demo uses `abca`. You can use any label you like, but it has to match what users will apply on Linear issues. + +### Step 4 — Configure the GitHub webhook + +This is what wires Vercel deploys back to ABCA's screenshot pipeline. + +#### 4a. Get the webhook URL + +```bash +aws cloudformation describe-stacks \ + --region us-east-1 \ + --stack-name \ + --query 'Stacks[0].Outputs[?OutputKey==`GitHubWebhookUrl`].OutputValue' \ + --output text +# → https://.execute-api.us-east-1.amazonaws.com/v1/github/webhook +``` + +#### 4b. Get the signing secret + +```bash +SECRET_ARN=$(aws cloudformation describe-stacks \ + --region us-east-1 \ + --stack-name \ + --query 'Stacks[0].Outputs[?OutputKey==`GitHubWebhookSecretArn`].OutputValue' \ + --output text) + +aws secretsmanager get-secret-value \ + --region us-east-1 \ + --secret-id "$SECRET_ARN" \ + --query SecretString --output text +``` + +#### 4c. Add the webhook on the GitHub repo + +1. Open `https://github.com///settings/hooks`. +2. Click **Add webhook**. +3. Fill in: + - **Payload URL**: the URL from 4a + - **Content type**: `application/json` + - **Secret**: the value from 4b + - **SSL verification**: leave enabled + - **Which events?**: choose "Let me select individual events", uncheck Pushes, check **Deployment statuses** only + - **Active**: ✓ +4. **Add webhook**. GitHub fires a `ping` event right away — under "Recent Deliveries" you should see ✅ within seconds. + +### Step 5 — Smoke test + +1. Open a Linear issue in your mapped project (e.g. "Update homepage heading"). It will get a Linear identifier like `ABCA-42`. +2. Add the `abca` label. +3. Wait 2-5 minutes: + - Agent reacts 👀 on the Linear issue (within ~10s) + - Agent does the work, opens a PR + - Vercel builds the preview (~30-60s) + - **Screenshot lands on the GitHub PR** as a comment + - **Same screenshot lands on the Linear issue** as a comment + +If the GitHub comment shows up but Linear doesn't (or vice versa), see Troubleshooting below. + +## Troubleshooting + +### GitHub webhook deliveries return 401 / 403 + +- **401 "Missing signature"**: the request didn't reach our Lambda — check that you saved the webhook with the right signing secret. +- **403 "Forbidden" with `X-Amzn-Errortype: ForbiddenException`**: WAF rejected the body. Should not happen on the `/v1/github/webhook` path because that path is exempted from the CommonRuleSet, but if you see it, check the `BlockedRequests` metric on the `TaskApiWebAcl` regional WebACL in CloudWatch. + +### Webhook delivers 200 but no screenshot lands + +Check the screenshot processor logs: + +```bash +aws lambda list-functions --region us-east-1 \ + --query "Functions[?contains(FunctionName, 'GitHubScreenshot') && contains(FunctionName, 'Processor')].FunctionName" \ + --output text +``` + +Then tail the function's CloudWatch log group. Common silent skips: + +- `skipped_state` — the delivery was for a non-`success` status (e.g. `pending`, `in_progress`); ignore. +- `skipped_environment` — Vercel reported the deploy as something other than `Preview`. The processor only screenshots Preview deploys by default; production hardening is a followup. +- `skipped_no_url` — the `success` status didn't include `environment_url`. Vercel does sometimes post URL-less success events; the next push usually carries the URL. +- `No open PR found for SHA after retries` — Vercel built and reported faster than the agent could `gh pr create` (race window > 35s). Rare; redeliver the webhook from GitHub's UI to retry. + +### Screenshot lands on GitHub PR but not on Linear + +The GitHub comment is the load-bearing path; Linear is best-effort. Look for the processor log line `Linear identifier did not resolve to an issue` — usually means: + +- The PR title and body don't contain a Linear-style identifier (e.g. `ABCA-42`). The agent's task description includes the identifier by default; if you opened the PR manually it might not. +- The identifier's workspace isn't OAuth-installed. Run `bgagent linear list-projects` to confirm the issue's project is in the registry. + +### CloudFront serves a 403 + +Visit the public URL directly: + +``` +https:///screenshots//.png +``` + +If it 403s, check that the bucket policy includes the OAC service principal (CDK should generate this automatically — re-deploy if it doesn't). + +### Vercel screenshots show a login page + +You forgot Step 2's "Vercel Authentication: Disabled" toggle. Toggle it off, push another commit, and confirm the next screenshot renders the actual app. + +## Production hardening (followups) + +The demo configuration optimizes for "look, it works" rather than security posture. Before using this on a real product: + +1. **Re-enable Vercel Standard Protection** + signed bypass token; teach the screenshot processor to inject `?x-vercel-protection-bypass=` on preview URLs (followup). +2. **Scope IAM down from `bedrock-agentcore:*`** to the specific Browser action set (followup, tracked). +3. **Add CloudFront access logs + WAF** if screenshots ever contain sensitive content. +4. **Tighten the screenshot retention** below 30 days if your privacy review requires it (constant in `cdk/src/constructs/screenshot-bucket.ts`). diff --git a/docs/scripts/sync-starlight.mjs b/docs/scripts/sync-starlight.mjs index 96c080c2..9da40980 100644 --- a/docs/scripts/sync-starlight.mjs +++ b/docs/scripts/sync-starlight.mjs @@ -45,6 +45,7 @@ function rewriteDocsLinkTarget(target) { CONTRIBUTING: '/developer-guide/contributing', SLACK_SETUP_GUIDE: '/using/slack-setup-guide', LINEAR_SETUP_GUIDE: '/using/linear-setup-guide', + VERCEL_SETUP_GUIDE: '/using/vercel-setup-guide', CEDAR_POLICY_GUIDE: '/customizing/cedar-policies', DEPLOYMENT_GUIDE: '/getting-started/deployment-guide', }; @@ -238,6 +239,12 @@ mirrorMarkdownFile( path.join('src', 'content', 'docs', 'using', 'Linear-setup-guide.md'), ); +// --- Vercel Setup Guide: mirror to using/ --- +mirrorMarkdownFile( + path.join(docsRoot, 'guides', 'VERCEL_SETUP_GUIDE.md'), + path.join('src', 'content', 'docs', 'using', 'Vercel-setup-guide.md'), +); + // --- Cedar Policy Guide: mirror to customizing/ (authoring reference for blueprint authors) --- mirrorMarkdownFile( path.join(docsRoot, 'guides', 'CEDAR_POLICY_GUIDE.md'), diff --git a/docs/src/content/docs/using/Vercel-setup-guide.md b/docs/src/content/docs/using/Vercel-setup-guide.md new file mode 100644 index 00000000..0af011ce --- /dev/null +++ b/docs/src/content/docs/using/Vercel-setup-guide.md @@ -0,0 +1,224 @@ +--- +title: Vercel setup guide +--- + +# Vercel preview screenshots setup guide + +This guide walks through wiring a Vercel-connected GitHub repo into ABCA so that every preview deploy gets screenshotted and posted as a comment on both the open GitHub PR **and** the linked Linear issue. + +> **Prerequisite phases:** Linear OAuth (Phase 2.0b — see [Linear setup guide](/using/linear-setup-guide)) must be installed before this guide is useful, since the screenshot-to-Linear leg reuses the per-workspace OAuth tokens from that path. + +## What you get + +When ABCA opens a PR for a Linear-driven task, Vercel deploys the preview, posts a `deployment_status` event back to GitHub, and ABCA's webhook receiver: + +1. Captures a full-page screenshot of the preview URL via AgentCore Browser +2. Uploads the PNG to a private S3 bucket served via CloudFront +3. Posts a markdown image comment on the open GitHub PR +4. Looks up the Linear issue (by identifier in the PR title/body — e.g. `ABCA-42`) and posts the same screenshot as a Linear comment + +End-to-end latency: typically 10–15 seconds after Vercel reports the deploy. + +## How it works + +``` +agent push → Vercel preview build → deployment_status webhook + ↓ + POST /v1/github/webhook + ↓ + receiver Lambda (HMAC verify, dedup) + ↓ + processor Lambda + ↓ + AgentCore Browser session + ↓ + PNG → private S3 (30-day TTL) + ↓ + CloudFront-served public URL + ↓ + GitHub PR comment + Linear issue comment +``` + +Architecture notes: + +- **Lambda-only.** No agent runtime is involved post-PR — the screenshot job is deterministic; an LLM would only add cost without changing behavior. +- **AWS-managed default browser.** AgentCore Browser ships an `aws.browser.v1` session you can attach to without provisioning your own browser resource. +- **Private S3 + CloudFront with OAC.** Screenshot bucket is fully private; CloudFront serves images anonymously over HTTPS so GitHub Markdown and Linear's image previews can render them without auth. +- **WAF exemption.** The `/v1/github/webhook` path is excluded from the AWSManagedRulesCommonRuleSet because Vercel `deployment_status` payloads (which embed absolute deploy URLs) trip `GenericRFI_BODY` otherwise. + +## Prerequisites + +- ABCA stack deployed (`mise //cdk:deploy` in this branch or later) — confirm `GitHubWebhookUrl` + `GitHubWebhookSecretArn` + `ScreenshotCloudFrontDomain` are listed in the stack outputs +- Linear OAuth installed for at least one workspace (`bgagent linear setup `) +- A GitHub repo you own AND where you can install the Vercel app +- A Vercel account that can import that repo +- AWS CLI logged in to the same account as the ABCA stack +- The `bgagent` CLI installed (`bgagent configure`, `bgagent login`) + +## Step-by-step setup + +### Step 1 — Connect Vercel to your GitHub repo + +1. Open https://vercel.com/dashboard. +2. **Add New** → **Project**. +3. Find your repo in the list (e.g. `your-org/vercel-abca-linear`). If it's not visible, click "Adjust GitHub App Permissions" and grant access. +4. Click **Import**. +5. Accept the framework defaults — Vercel auto-detects most stacks. +6. Click **Deploy**. Wait for the first deploy to finish. + +### Step 2 — Vercel project settings + +Go to **your-project → Settings** in the Vercel dashboard. + +#### Settings → Git +- **Connected Git Repository**: confirm the repo is listed. +- **`deployment_status` Events**: toggle **Enabled** (this is what tells Vercel to post the webhook to GitHub when each deploy finishes). +- **Pull Request Comments**: optional — Vercel's own comment with the preview URL. Doesn't affect ABCA either way. + +#### Settings → Deployment Protection +- **Vercel Authentication**: set to **Disabled** (or "Only Production Deployments") for the demo. Otherwise AgentCore Browser will hit a Vercel auth wall and screenshot the login page instead of your app. + +> **Production hardening.** When you graduate the demo to a real production setup, switch Vercel Authentication back to **Standard Protection** and configure a [signed bypass token](https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection#protection-bypass-for-automation). The screenshot processor will need to inject the bypass token as a query parameter on the preview URL — this is tracked as a followup. + +### Step 3 — Onboard the repo to ABCA + +ABCA needs to know the repo is allowed to receive tasks. Two writes: + +#### 3a. Register the repo in `RepoTable` + +There's no CLI helper today; do a direct DDB put. Replace the table name with your stack's value (`aws cloudformation describe-stacks ... RepoTableName`): + +```bash +aws dynamodb put-item --region us-east-1 \ + --table-name \ + --item '{ + "repo": {"S": "your-org/your-vercel-repo"}, + "status": {"S": "active"}, + "onboarded_at": {"S": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}, + "updated_at": {"S": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"} + }' +``` + +#### 3b. Map a Linear project → this repo + +```bash +# Find the Linear project UUID +bgagent linear list-projects + +# Map it to the repo +bgagent linear onboard-project \ + --repo your-org/your-vercel-repo \ + --label abca +``` + +The `--label` controls which Linear label triggers a task. Defaults to `bgagent`; the demo uses `abca`. You can use any label you like, but it has to match what users will apply on Linear issues. + +### Step 4 — Configure the GitHub webhook + +This is what wires Vercel deploys back to ABCA's screenshot pipeline. + +#### 4a. Get the webhook URL + +```bash +aws cloudformation describe-stacks \ + --region us-east-1 \ + --stack-name \ + --query 'Stacks[0].Outputs[?OutputKey==`GitHubWebhookUrl`].OutputValue' \ + --output text +# → https://.execute-api.us-east-1.amazonaws.com/v1/github/webhook +``` + +#### 4b. Get the signing secret + +```bash +SECRET_ARN=$(aws cloudformation describe-stacks \ + --region us-east-1 \ + --stack-name \ + --query 'Stacks[0].Outputs[?OutputKey==`GitHubWebhookSecretArn`].OutputValue' \ + --output text) + +aws secretsmanager get-secret-value \ + --region us-east-1 \ + --secret-id "$SECRET_ARN" \ + --query SecretString --output text +``` + +#### 4c. Add the webhook on the GitHub repo + +1. Open `https://github.com///settings/hooks`. +2. Click **Add webhook**. +3. Fill in: + - **Payload URL**: the URL from 4a + - **Content type**: `application/json` + - **Secret**: the value from 4b + - **SSL verification**: leave enabled + - **Which events?**: choose "Let me select individual events", uncheck Pushes, check **Deployment statuses** only + - **Active**: ✓ +4. **Add webhook**. GitHub fires a `ping` event right away — under "Recent Deliveries" you should see ✅ within seconds. + +### Step 5 — Smoke test + +1. Open a Linear issue in your mapped project (e.g. "Update homepage heading"). It will get a Linear identifier like `ABCA-42`. +2. Add the `abca` label. +3. Wait 2-5 minutes: + - Agent reacts 👀 on the Linear issue (within ~10s) + - Agent does the work, opens a PR + - Vercel builds the preview (~30-60s) + - **Screenshot lands on the GitHub PR** as a comment + - **Same screenshot lands on the Linear issue** as a comment + +If the GitHub comment shows up but Linear doesn't (or vice versa), see Troubleshooting below. + +## Troubleshooting + +### GitHub webhook deliveries return 401 / 403 + +- **401 "Missing signature"**: the request didn't reach our Lambda — check that you saved the webhook with the right signing secret. +- **403 "Forbidden" with `X-Amzn-Errortype: ForbiddenException`**: WAF rejected the body. Should not happen on the `/v1/github/webhook` path because that path is exempted from the CommonRuleSet, but if you see it, check the `BlockedRequests` metric on the `TaskApiWebAcl` regional WebACL in CloudWatch. + +### Webhook delivers 200 but no screenshot lands + +Check the screenshot processor logs: + +```bash +aws lambda list-functions --region us-east-1 \ + --query "Functions[?contains(FunctionName, 'GitHubScreenshot') && contains(FunctionName, 'Processor')].FunctionName" \ + --output text +``` + +Then tail the function's CloudWatch log group. Common silent skips: + +- `skipped_state` — the delivery was for a non-`success` status (e.g. `pending`, `in_progress`); ignore. +- `skipped_environment` — Vercel reported the deploy as something other than `Preview`. The processor only screenshots Preview deploys by default; production hardening is a followup. +- `skipped_no_url` — the `success` status didn't include `environment_url`. Vercel does sometimes post URL-less success events; the next push usually carries the URL. +- `No open PR found for SHA after retries` — Vercel built and reported faster than the agent could `gh pr create` (race window > 35s). Rare; redeliver the webhook from GitHub's UI to retry. + +### Screenshot lands on GitHub PR but not on Linear + +The GitHub comment is the load-bearing path; Linear is best-effort. Look for the processor log line `Linear identifier did not resolve to an issue` — usually means: + +- The PR title and body don't contain a Linear-style identifier (e.g. `ABCA-42`). The agent's task description includes the identifier by default; if you opened the PR manually it might not. +- The identifier's workspace isn't OAuth-installed. Run `bgagent linear list-projects` to confirm the issue's project is in the registry. + +### CloudFront serves a 403 + +Visit the public URL directly: + +``` +https:///screenshots//.png +``` + +If it 403s, check that the bucket policy includes the OAC service principal (CDK should generate this automatically — re-deploy if it doesn't). + +### Vercel screenshots show a login page + +You forgot Step 2's "Vercel Authentication: Disabled" toggle. Toggle it off, push another commit, and confirm the next screenshot renders the actual app. + +## Production hardening (followups) + +The demo configuration optimizes for "look, it works" rather than security posture. Before using this on a real product: + +1. **Re-enable Vercel Standard Protection** + signed bypass token; teach the screenshot processor to inject `?x-vercel-protection-bypass=` on preview URLs (followup). +2. **Scope IAM down from `bedrock-agentcore:*`** to the specific Browser action set (followup, tracked). +3. **Add CloudFront access logs + WAF** if screenshots ever contain sensitive content. +4. **Tighten the screenshot retention** below 30 days if your privacy review requires it (constant in `cdk/src/constructs/screenshot-bucket.ts`). diff --git a/yarn.lock b/yarn.lock index 62992cfb..73ef80dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5232,6 +5232,13 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== +"@types/ws@^8.5.13": + version "8.18.1" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" + integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -11580,6 +11587,11 @@ write-file-atomic@^5.0.1: imurmurhash "^0.1.4" signal-exit "^4.0.1" +ws@^8.18.0: + version "8.20.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" + integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== + xml-naming@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.1.0.tgz#8ab7106c5b8d23caa2fabac1cadf17136379fbd8"