From 2b572115efa64eedc68a9a932b3e64d3d65b9a2a Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 13:57:36 -0700 Subject: [PATCH 01/24] feat(screenshot): preview-deploy screenshot pipeline (no stack wiring yet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lambda + AgentCore Browser plumbing for capturing screenshots of preview deployments. Provider-agnostic — listens for GitHub deployment_status events from any source (Vercel, Amplify Hosting, Netlify, GitHub Actions custom CD). This commit lands the handler / construct code only. Stack wiring follows in the next commit. --- cdk/package.json | 4 + cdk/src/constructs/screenshot-bucket.ts | 140 ++++++++ cdk/src/handlers/github-webhook-processor.ts | 256 ++++++++++++++ cdk/src/handlers/github-webhook.ts | 244 +++++++++++++ cdk/src/handlers/shared/agentcore-browser.ts | 328 ++++++++++++++++++ .../handlers/shared/github-webhook-verify.ts | 127 +++++++ 6 files changed, 1099 insertions(+) create mode 100644 cdk/src/constructs/screenshot-bucket.ts create mode 100644 cdk/src/handlers/github-webhook-processor.ts create mode 100644 cdk/src/handlers/github-webhook.ts create mode 100644 cdk/src/handlers/shared/agentcore-browser.ts create mode 100644 cdk/src/handlers/shared/github-webhook-verify.ts diff --git a/cdk/package.json b/cdk/package.json index fecad531..534a4769 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -17,6 +17,7 @@ "@aws-cdk/aws-bedrock-agentcore-alpha": "2.257.0-alpha.0", "@aws-cdk/aws-bedrock-alpha": "2.257.0-alpha.0", "@aws-cdk/mixins-preview": "2.257.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-dynamodb": "^3.1021.0", @@ -24,11 +25,14 @@ "@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-presigned-post": "^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.257.0", "cdk-nag": "^2.38.2", "constructs": "^10.3.0", diff --git a/cdk/src/constructs/screenshot-bucket.ts b/cdk/src/constructs/screenshot-bucket.ts new file mode 100644 index 00000000..b97ae517 --- /dev/null +++ b/cdk/src/constructs/screenshot-bucket.ts @@ -0,0 +1,140 @@ +/** + * 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, Stack } from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +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 bucket policy grants public + * ``s3:GetObject`` on this prefix only — anything written outside is + * invisible to anonymous readers. + */ +export const SCREENSHOT_KEY_PREFIX = 'screenshots/'; + +/** + * Build the public HTTPS URL for a screenshot object. Path-style URL is + * intentional — virtual-hosted style breaks for buckets with dots in + * the name (CDK auto-generated names sometimes include dots when the + * region is appended). + */ +export function screenshotPublicUrl(bucket: s3.IBucket, key: string): string { + const region = Stack.of(bucket).region; + return `https://${bucket.bucketName}.s3.${region}.amazonaws.com/${key}`; +} + +/** + * Properties for ScreenshotBucket construct. + */ +export interface ScreenshotBucketProps { + /** + * Removal policy for the bucket. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to auto-delete objects when the bucket is removed. + * @default true + */ + readonly autoDeleteObjects?: boolean; +} + +/** + * S3 bucket hosting screenshot PNGs that the agent embeds in GitHub PR + * + Linear issue comments. + * + * The agent writes ``screenshots/.png`` after AgentCore Browser + * captures the deployed GitHub Pages URL. Both GitHub Markdown rendering + * and Linear's image previews fetch the URL anonymously, so the prefix + * is configured for unauthenticated reads. + * + * Security shape: + * - ``blockPublicAcls`` and ``ignorePublicAcls`` true — no per-object ACLs + * can grant access; only the bucket policy decides. + * - ``blockPublicPolicy`` and ``restrictPublicBuckets`` false — the policy + * intentionally grants public read on ``screenshots/*``. + * - Bucket policy: anonymous ``s3:GetObject`` limited to the + * ``screenshots/*`` key prefix and TLS-only transport. Writes still + * require IAM (the agent's runtime role). + * - SSE-S3 at rest, ``enforceSSL`` true. + * - 30-day lifecycle so screenshots don't accumulate forever. + */ +export class ScreenshotBucket extends Construct { + /** The underlying S3 bucket. */ + public readonly bucket: s3.Bucket; + + constructor(scope: Construct, id: string, props: ScreenshotBucketProps = {}) { + super(scope, id); + + this.bucket = new s3.Bucket(this, 'Bucket', { + // Allow public bucket policy (the next statement); deny public ACLs. + blockPublicAccess: new s3.BlockPublicAccess({ + blockPublicAcls: true, + ignorePublicAcls: true, + blockPublicPolicy: false, + restrictPublicBuckets: false, + }), + 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, + }); + + // Public read on the screenshots/ prefix only. Both GitHub markdown + // and Linear's `imageUploadFromUrl` need to GET the URL anonymously. + this.bucket.addToResourcePolicy(new iam.PolicyStatement({ + sid: 'AllowAnonymousReadOfScreenshotsPrefix', + effect: iam.Effect.ALLOW, + principals: [new iam.AnyPrincipal()], + actions: ['s3:GetObject'], + resources: [`${this.bucket.bucketArn}/${SCREENSHOT_KEY_PREFIX}*`], + conditions: { + Bool: { 'aws:SecureTransport': 'true' }, + }, + })); + + 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 and Linear issues. Adding access logging would generate substantial log volume for a low-value security signal — public reads are by design and the prefix is scoped to PNG renders only.', + }, + { + id: 'AwsSolutions-S5', + reason: + 'Public-read on screenshots/* is intentional — GitHub markdown renderers and Linear imageUploadFromUrl both require anonymous GET on the embedded image URL. Followup #79 will move to CloudFront with signed URLs once the feature stabilizes.', + }, + ], true); + } +} diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts new file mode 100644 index 00000000..54ba98d9 --- /dev/null +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -0,0 +1,256 @@ +/** + * 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 { logger } from './shared/logger'; + +const s3 = new S3Client({}); + +const SCREENSHOT_BUCKET = process.env.SCREENSHOT_BUCKET_NAME!; +const GITHUB_TOKEN_SECRET_ARN = process.env.GITHUB_TOKEN_SECRET_ARN!; +const REGION = process.env.AWS_REGION ?? 'us-east-1'; + +interface GitHubDeploymentStatusPayload { + readonly action?: string; + readonly deployment_status?: { + readonly id?: number; + readonly state?: string; + readonly target_url?: string; + }; + readonly deployment?: { + readonly id?: number; + readonly sha?: string; + readonly environment?: string; + readonly environment_url?: 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; + const previewUrl = payload.deployment?.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; + } + + const prNumber = await findPullRequestForSha(repo, sha, token); + if (!prNumber) { + logger.info('No open PR found for SHA — 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 = buildPublicUrl(SCREENSHOT_BUCKET, key); + const commentBody = renderCommentBody(publicUrl, previewUrl); + + try { + const result = await upsertTaskComment({ + repo, + issueOrPrNumber: prNumber, + 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: prNumber, + comment_id: result.commentId, + public_url: publicUrl, + }); + } catch (err) { + logger.warn('Failed to post screenshot PR comment (non-fatal)', { + repo, + pr_number: prNumber, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * 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's number, 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 }>; + const open = pulls.find((p) => p.state === 'open' && typeof p.number === 'number'); + return open?.number ?? null; +} + +/** 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`; +} + +/** Build the public-readable HTTPS URL for an S3 object in the screenshot bucket. */ +function buildPublicUrl(bucket: string, key: string): string { + return `https://${bucket}.s3.${REGION}.amazonaws.com/${key}`; +} + +/** 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'); +} diff --git a/cdk/src/handlers/github-webhook.ts b/cdk/src/handlers/github-webhook.ts new file mode 100644 index 00000000..98efaad7 --- /dev/null +++ b/cdk/src/handlers/github-webhook.ts @@ -0,0 +1,244 @@ +/** + * 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.environment`: `Preview` | `Production` + * - `deployment.environment_url`: the deployed URL (used by the agent + * as the screenshot target — no extra round-trip needed) + * - `deployment.sha`: the commit SHA the deploy is for (used to map + * back to an ABCA task via the RepoCommitIndex GSI) + * + * 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 deployment?: { + readonly id?: number; + readonly sha?: string; + readonly environment?: string; + readonly environment_url?: 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?.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/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts new file mode 100644 index 00000000..4e497a4f --- /dev/null +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -0,0 +1,328 @@ +/** + * 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 { 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 { + const headers = await sigV4WsHeaders(wssUrl); + + // The WebSocket constructor in Node 24 doesn't accept custom headers + // directly. Use the lower-level `undici` WebSocket via the `headers` + // option — but the standard `WebSocket` does NOT expose that. Workaround: + // attach the SigV4 headers as protocol fields. AWS's WSS handshake reads + // both Authorization headers and Sec-WebSocket-Protocol-encoded variants. + // + // Simpler: open with the classic `Authorization` style by passing + // headers via the dispatcher. Node 24 exposes `WebSocket` from undici + // which DOES support this through `globalThis.WebSocket`'s second arg. + const ws = new WebSocket(wssUrl, { headers } as unknown as string[]); + + 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.addEventListener('message', (event) => { + const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); + 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. + await new Promise((resolve, reject) => { + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (e: Event) => { + cleanup(); + reject(new Error(`AgentCore Browser WebSocket error: ${(e as ErrorEvent).message ?? '(no message)'}`)); + }; + const cleanup = () => { + ws.removeEventListener('open', onOpen); + ws.removeEventListener('error', onError); + }; + ws.addEventListener('open', onOpen); + ws.addEventListener('error', onError); + 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 */ } + } +} + +/** + * Build SigV4-signed headers for the WebSocket upgrade request. AgentCore + * Browser's WSS endpoint expects the same SigV4 envelope as a regular + * `bedrock-agentcore` HTTPS call. + */ +async function sigV4WsHeaders(wssUrl: string): Promise> { + const u = new URL(wssUrl); + const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region: REGION, + credentials: defaultProvider(), + sha256: Sha256, + }); + const req = new HttpRequest({ + method: 'GET', + protocol: 'https:', + hostname: u.hostname, + path: u.pathname + u.search, + headers: { + host: u.hostname, + }, + }); + const signed = await signer.sign(req); + return signed.headers; +} 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); +} From ca5ab14f0f5d169e7f4f1d5540d2211141423a02 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 14:09:52 -0700 Subject: [PATCH 02/24] feat(screenshot): GitHubScreenshotIntegration construct + stack wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `GitHubScreenshotIntegration` construct (mirrors `LinearIntegration`): bundles the screenshot bucket, dedup table, signing-secret placeholder, receiver Lambda, processor Lambda, and the API Gateway route. cdk-nag suppressions added inline (HMAC auth instead of Cognito; AgentCore Browser sessions have no per-resource ARN; Secrets Manager rotation is owned by GitHub). - Wired into `agent.ts` after the LinearIntegration block. Reuses the existing `githubTokenSecret` (the processor uses ABCA's main GitHub token to look up which PR a deploy SHA belongs to and post the screenshot comment — no new credential). - Three new stack outputs: `GitHubWebhookUrl`, `GitHubWebhookSecretArn`, `ScreenshotBucketName`. - Bumped agent.test.ts table count from 13 to 14 to account for the new dedup table. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../github-screenshot-integration.ts | 235 ++++++++++++++++++ cdk/src/stacks/agent.ts | 27 ++ cdk/test/stacks/agent.test.ts | 7 +- 3 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 cdk/src/constructs/github-screenshot-integration.ts diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts new file mode 100644 index 00000000..90403039 --- /dev/null +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -0,0 +1,235 @@ +/** + * 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 { Duration, RemovalPolicy } 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; + + /** + * 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, + GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, + }, + bundling: commonBundling, + }); + + this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn); + props.githubTokenSecret.grantRead(this.webhookProcessorFn); + + // AgentCore Browser session lifecycle. The data-plane API doesn't + // support per-resource ARNs (sessions are ephemeral), so wildcards + // are required — annotated with a cdk-nag suppression below. + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:StartBrowserSession', + 'bedrock-agentcore:StopBrowserSession', + 'bedrock-agentcore:GetBrowserSession', + 'bedrock-agentcore:UpdateBrowserStream', + ], + 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/stacks/agent.ts b/cdk/src/stacks/agent.ts index 9ab63dd6..4f6a5c40 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -42,6 +42,7 @@ import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; // import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; import { FanOutConsumer } from '../constructs/fanout-consumer'; +import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-integration'; import { LinearIntegration } from '../constructs/linear-integration'; import { PendingUploadCleanup } from '../constructs/pending-upload-cleanup'; import { RepoTable } from '../constructs/repo-table'; @@ -834,6 +835,32 @@ 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, + }); + + 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: 'S3 bucket hosting Vercel-preview screenshots (public read on screenshots/* prefix)', + }); + // --- 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/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', () => { From 8138e8681138d8a4599cb56e69bb8353ae6ad86a Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 14:11:16 -0700 Subject: [PATCH 03/24] fix(screenshot): suppress AwsSolutions-S2 on the public-read screenshot bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cdk-nag's S2 fires on any bucket that has `blockPublicPolicy: false` even when the policy is intentionally permissive. Add the suppression with the same rationale as S1/S5 — public reads are required by GitHub Markdown renderers and Linear `imageUploadFromUrl`, and the read grant is prefix-scoped to `screenshots/*`. Caught when the first deploy attempt aborted at synth-time on the new GitHubScreenshotIntegration construct. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/constructs/screenshot-bucket.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cdk/src/constructs/screenshot-bucket.ts b/cdk/src/constructs/screenshot-bucket.ts index b97ae517..ce2b230b 100644 --- a/cdk/src/constructs/screenshot-bucket.ts +++ b/cdk/src/constructs/screenshot-bucket.ts @@ -130,6 +130,11 @@ export class ScreenshotBucket extends Construct { reason: 'Server access logs are not enabled for this bucket; screenshots are ephemeral artifacts (30-day TTL) embedded in GitHub PR comments and Linear issues. Adding access logging would generate substantial log volume for a low-value security signal — public reads are by design and the prefix is scoped to PNG renders only.', }, + { + id: 'AwsSolutions-S2', + reason: + 'Public-read on screenshots/* is intentional — GitHub markdown renderers and Linear `imageUploadFromUrl` both fetch the URL anonymously. The bucket policy is prefix-scoped (only `screenshots/*` is readable), and `blockPublicAcls`+`ignorePublicAcls` are still on so per-object ACLs can never override.', + }, { id: 'AwsSolutions-S5', reason: From 235710ee1c8cb2c3cfce2b447682d591dc3ce917 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 14:21:55 -0700 Subject: [PATCH 04/24] fix(screenshot): private S3 bucket + CloudFront distribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first deploy attempt failed at CFN-execute time on the bucket policy: s3:PutBucketPolicy ... because public policies are prevented by the BlockPublicPolicy setting in S3 Block Public Access. Account-level Block Public Access is on for this AWS account, which overrides per-bucket BPA settings. Disabling it would change the security posture of the whole account, so route around the constraint with the AWS-recommended pattern: private S3 + CloudFront with Origin Access Control. Changes: - `ScreenshotBucket` is now `BLOCK_ALL` BPA, no public bucket policy. Adds a `cloudfront.Distribution` whose origin is the bucket via `S3BucketOrigin.withOriginAccessControl`. The distribution policy is scoped to the CloudFront service principal only, so account-level BPA accepts it. - Processor reads `SCREENSHOT_PUBLIC_HOST` (the CloudFront domain) instead of building an S3 URL. PR comments now embed `https://.cloudfront.net/screenshots/...` URLs. - New stack output `ScreenshotCloudFrontDomain`. - Bucket-level S2/S5 suppressions removed (no longer applicable — bucket is private). Distribution gets CFR1/CFR2/CFR3/CFR4/CFR7 suppressions with rationales. Heads up on deploy time: CloudFront distributions take 5-15 min to provision on first create. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../github-screenshot-integration.ts | 1 + cdk/src/constructs/screenshot-bucket.ts | 125 +++++++++--------- cdk/src/handlers/github-webhook-processor.ts | 13 +- cdk/src/stacks/agent.ts | 7 +- 4 files changed, 79 insertions(+), 67 deletions(-) diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts index 90403039..3a96f9a1 100644 --- a/cdk/src/constructs/github-screenshot-integration.ts +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -140,6 +140,7 @@ export class GitHubScreenshotIntegration extends Construct { memorySize: 512, environment: { SCREENSHOT_BUCKET_NAME: this.screenshotBucket.bucket.bucketName, + SCREENSHOT_PUBLIC_HOST: this.screenshotBucket.distribution.domainName, GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, }, bundling: commonBundling, diff --git a/cdk/src/constructs/screenshot-bucket.ts b/cdk/src/constructs/screenshot-bucket.ts index ce2b230b..76c4b6b7 100644 --- a/cdk/src/constructs/screenshot-bucket.ts +++ b/cdk/src/constructs/screenshot-bucket.ts @@ -17,8 +17,9 @@ * SOFTWARE. */ -import { Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; -import * as iam from 'aws-cdk-lib/aws-iam'; +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'; @@ -28,29 +29,18 @@ export const SCREENSHOT_TTL_DAYS = 30; /** * Object-key prefix for all screenshots. Key layout: - * ``screenshots/.png``. The bucket policy grants public - * ``s3:GetObject`` on this prefix only — anything written outside is - * invisible to anonymous readers. + * ``screenshots//.png``. The CloudFront distribution serves + * the entire bucket, but the processor only ever writes under this + * prefix. */ export const SCREENSHOT_KEY_PREFIX = 'screenshots/'; -/** - * Build the public HTTPS URL for a screenshot object. Path-style URL is - * intentional — virtual-hosted style breaks for buckets with dots in - * the name (CDK auto-generated names sometimes include dots when the - * region is appended). - */ -export function screenshotPublicUrl(bucket: s3.IBucket, key: string): string { - const region = Stack.of(bucket).region; - return `https://${bucket.bucketName}.s3.${region}.amazonaws.com/${key}`; -} - /** * Properties for ScreenshotBucket construct. */ export interface ScreenshotBucketProps { /** - * Removal policy for the bucket. + * Removal policy for the bucket + distribution. * @default RemovalPolicy.DESTROY */ readonly removalPolicy?: RemovalPolicy; @@ -63,40 +53,37 @@ export interface ScreenshotBucketProps { } /** - * S3 bucket hosting screenshot PNGs that the agent embeds in GitHub PR - * + Linear issue comments. + * 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. * - * The agent writes ``screenshots/.png`` after AgentCore Browser - * captures the deployed GitHub Pages URL. Both GitHub Markdown rendering - * and Linear's image previews fetch the URL anonymously, so the prefix - * is configured for unauthenticated reads. + * Layout: + * s3:///screenshots//.png (private) + * https://.cloudfront.net/screenshots//.png (anon) * - * Security shape: - * - ``blockPublicAcls`` and ``ignorePublicAcls`` true — no per-object ACLs - * can grant access; only the bucket policy decides. - * - ``blockPublicPolicy`` and ``restrictPublicBuckets`` false — the policy - * intentionally grants public read on ``screenshots/*``. - * - Bucket policy: anonymous ``s3:GetObject`` limited to the - * ``screenshots/*`` key prefix and TLS-only transport. Writes still - * require IAM (the agent's runtime role). - * - SSE-S3 at rest, ``enforceSSL`` true. - * - 30-day lifecycle so screenshots don't accumulate forever. + * 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 S3 bucket. */ + /** 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', { - // Allow public bucket policy (the next statement); deny public ACLs. - blockPublicAccess: new s3.BlockPublicAccess({ - blockPublicAcls: true, - ignorePublicAcls: true, - blockPublicPolicy: false, - restrictPublicBuckets: false, - }), + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, encryption: s3.BucketEncryption.S3_MANAGED, enforceSSL: true, lifecycleRules: [ @@ -111,34 +98,54 @@ export class ScreenshotBucket extends Construct { autoDeleteObjects: props.autoDeleteObjects ?? true, }); - // Public read on the screenshots/ prefix only. Both GitHub markdown - // and Linear's `imageUploadFromUrl` need to GET the URL anonymously. - this.bucket.addToResourcePolicy(new iam.PolicyStatement({ - sid: 'AllowAnonymousReadOfScreenshotsPrefix', - effect: iam.Effect.ALLOW, - principals: [new iam.AnyPrincipal()], - actions: ['s3:GetObject'], - resources: [`${this.bucket.bucketArn}/${SCREENSHOT_KEY_PREFIX}*`], - conditions: { - Bool: { 'aws:SecureTransport': '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 and Linear issues. Adding access logging would generate substantial log volume for a low-value security signal — public reads are by design and the prefix is scoped to PNG renders only.', + '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-S2', - reason: - 'Public-read on screenshots/* is intentional — GitHub markdown renderers and Linear `imageUploadFromUrl` both fetch the URL anonymously. The bucket policy is prefix-scoped (only `screenshots/*` is readable), and `blockPublicAcls`+`ignorePublicAcls` are still on so per-object ACLs can never override.', + 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-S5', - reason: - 'Public-read on screenshots/* is intentional — GitHub markdown renderers and Linear imageUploadFromUrl both require anonymous GET on the embedded image URL. Followup #79 will move to CloudFront with signed URLs once the feature stabilizes.', + 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/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index 54ba98d9..da1972c6 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -26,8 +26,12 @@ 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!; -const REGION = process.env.AWS_REGION ?? 'us-east-1'; interface GitHubDeploymentStatusPayload { readonly action?: string; @@ -156,7 +160,7 @@ export async function handler(event: ProcessorEvent): Promise { return; } - const publicUrl = buildPublicUrl(SCREENSHOT_BUCKET, key); + const publicUrl = `https://${SCREENSHOT_PUBLIC_HOST}/${key}`; const commentBody = renderCommentBody(publicUrl, previewUrl); try { @@ -239,11 +243,6 @@ function buildScreenshotKey(repo: string, sha: string, deploymentId: number | un return `screenshots/${repoSlug}/${sha}${id}.png`; } -/** Build the public-readable HTTPS URL for an S3 object in the screenshot bucket. */ -function buildPublicUrl(bucket: string, key: string): string { - return `https://${bucket}.s3.${REGION}.amazonaws.com/${key}`; -} - /** Render the PR comment body. */ function renderCommentBody(publicUrl: string, previewUrl: string): string { return [ diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 4f6a5c40..8dbe0d72 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -858,7 +858,12 @@ export class AgentStack extends Stack { new CfnOutput(this, 'ScreenshotBucketName', { value: githubScreenshot.screenshotBucket.bucket.bucketName, - description: 'S3 bucket hosting Vercel-preview screenshots (public read on screenshots/* prefix)', + 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) --- From 36e8d1438e71b3a2e6b879a23921d341bdbb339e Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 17:42:03 -0700 Subject: [PATCH 05/24] fix(waf): exempt /v1/github/webhook from CRS like /v1/linear/webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CommonRuleSet was 403'ing GitHub deployment_status webhooks before the request reached our Lambda — the deployment payload contains absolute Vercel preview URLs in the body, which trips GenericRFI_BODY. Mirror the Linear webhook exemption: the GitHub webhook path is HMAC-verified in the Lambda, parsed as strict JSON, never interpolated into SQL/HTML, and rate-limited by the priority-3 rule. CRS still applies to every other route. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/constructs/task-api.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 6ecd745a..cf6ec312 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -295,6 +295,17 @@ export class TaskApi extends Construct { textTransformations: [{ priority: 0, type: 'NONE' }], }, }, + { + // GitHub deployment_status webhook (Vercel preview + // screenshot pipeline) — absolute deploy URLs trip + // GenericRFI_BODY. HMAC-verified in Lambda. + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/github/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, ], }, }, @@ -342,6 +353,18 @@ export class TaskApi extends Construct { }, }, }, + { + notStatement: { + statement: { + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/github/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, + }, + }, ], }, }, From bb5e5d170d6cbfea433cf3490009371414e97fb4 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 18:07:07 -0700 Subject: [PATCH 06/24] fix(screenshot): read environment_url from deployment_status, not deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub's `deployment_status` webhook puts the deployed URL on the *status* object, not the deployment itself. The deployment object is immutable per (sha, environment); the status changes through the deploy lifecycle (`pending` → `success`) and carries the URL only once the deploy finishes. Symptom: receiver kept short-circuiting `success` events from Vercel with `{ok: true, skipped_no_url: true}` because we read the wrong field. Verified by inspecting the webhook delivery payload via `gh api .../deliveries/ --jq .request.payload.deployment_status` — URL was there all along. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/handlers/github-webhook-processor.ts | 7 +++++-- cdk/src/handlers/github-webhook.ts | 13 ++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index da1972c6..935ffe3d 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -39,12 +39,13 @@ interface GitHubDeploymentStatusPayload { 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 environment_url?: string; }; readonly repository?: { readonly full_name?: string; @@ -89,7 +90,9 @@ export async function handler(event: ProcessorEvent): Promise { const repo = payload.repository?.full_name; const sha = payload.deployment?.sha; - const previewUrl = payload.deployment?.environment_url; + // 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) { diff --git a/cdk/src/handlers/github-webhook.ts b/cdk/src/handlers/github-webhook.ts index 98efaad7..bad8f345 100644 --- a/cdk/src/handlers/github-webhook.ts +++ b/cdk/src/handlers/github-webhook.ts @@ -43,11 +43,14 @@ const DEDUP_TTL_SECONDS = 60 * 60; * (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.environment_url`: the deployed URL (used by the agent - * as the screenshot target — no extra round-trip needed) * - `deployment.sha`: the commit SHA the deploy is for (used to map - * back to an ABCA task via the RepoCommitIndex GSI) + * 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. @@ -57,12 +60,12 @@ interface GitHubDeploymentStatusEnvelope { 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 environment_url?: string; }; readonly repository?: { readonly full_name?: string; @@ -164,7 +167,7 @@ export async function handler(event: APIGatewayProxyEvent): Promise Date: Wed, 20 May 2026 18:15:11 -0700 Subject: [PATCH 07/24] fix(agentcore-browser): use ws package for SigV4-signed WebSocket handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 24's global WebSocket (from undici) does NOT support arbitrary HTTP headers on the upgrade request — passing them as the second arg gets silently ignored. AgentCore Browser's WSS handshake requires SigV4-signed Authorization + X-Amz-* headers, so the connection was opening but then getting rejected, which surfaced as an empty `error` event ("AgentCore Browser WebSocket error: "). Switch to the `ws` package which natively supports `options.headers`. Also add an `unexpected-response` handler so HTTP-level handshake failures (403, 400) surface with status codes instead of empty errors. Smoke verified locally — the ws-based path opens cleanly against example.com and Vercel preview URLs. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/package.json | 6 ++- cdk/src/handlers/shared/agentcore-browser.ts | 48 +++++++++++--------- yarn.lock | 12 +++++ 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/cdk/package.json b/cdk/package.json index 534a4769..6f9e38b0 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -36,9 +36,10 @@ "aws-cdk-lib": "^2.257.0", "cdk-nag": "^2.38.2", "constructs": "^10.3.0", - "pdf-parse": "^1.1.1", "js-yaml": "^4.1.1", - "ulid": "^3.0.2" + "pdf-parse": "^1.1.1", + "ulid": "^3.0.2", + "ws": "^8.18.0" }, "devDependencies": { "@cdklabs/eslint-plugin": "^2", @@ -48,6 +49,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/pdf-parse": "^1.1.4", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "aws-cdk": "^2", diff --git a/cdk/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts index 4e497a4f..5660b444 100644 --- a/cdk/src/handlers/shared/agentcore-browser.ts +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -26,6 +26,7 @@ import { 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'; @@ -133,16 +134,12 @@ export async function captureScreenshot(url: string, opts: { timeoutMs?: number async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): Promise { const headers = await sigV4WsHeaders(wssUrl); - // The WebSocket constructor in Node 24 doesn't accept custom headers - // directly. Use the lower-level `undici` WebSocket via the `headers` - // option — but the standard `WebSocket` does NOT expose that. Workaround: - // attach the SigV4 headers as protocol fields. AWS's WSS handshake reads - // both Authorization headers and Sec-WebSocket-Protocol-encoded variants. - // - // Simpler: open with the classic `Authorization` style by passing - // headers via the dispatcher. Node 24 exposes `WebSocket` from undici - // which DOES support this through `globalThis.WebSocket`'s second arg. - const ws = new WebSocket(wssUrl, { headers } as unknown as string[]); + // Use `ws` (the standard Node WebSocket client) — its constructor accepts + // custom HTTP headers on the upgrade GET via `options.headers`. Node 24's + // global `WebSocket` (from undici) does NOT accept arbitrary headers, and + // AgentCore Browser's WSS handshake requires SigV4-signed `Authorization` + // + `X-Amz-*` headers, so we have to inject them somehow. + const ws = new WebSocket(wssUrl, { headers }); const deadline = Date.now() + timeoutMs; const remaining = () => Math.max(0, deadline - Date.now()); @@ -158,8 +155,8 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): } const eventWaiters: EventWaiter[] = []; - ws.addEventListener('message', (event) => { - const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); + ws.on('message', (raw: RawData) => { + const data = raw.toString(); let msg: CdpMessage; try { msg = JSON.parse(data) as CdpMessage; @@ -187,22 +184,31 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): } }); - // Open the socket. + // 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 = () => { + const onOpen = (): void => { cleanup(); resolve(); }; - const onError = (e: Event) => { + const onError = (err: Error): void => { cleanup(); - reject(new Error(`AgentCore Browser WebSocket error: ${(e as ErrorEvent).message ?? '(no message)'}`)); + reject(new Error(`AgentCore Browser WebSocket error: ${err.message || '(no message)'}`)); }; - const cleanup = () => { - ws.removeEventListener('open', onOpen); - ws.removeEventListener('error', onError); + 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.addEventListener('open', onOpen); - ws.addEventListener('error', onError); + 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`)); diff --git a/yarn.lock b/yarn.lock index 32239b57..d7245c24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5596,6 +5596,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" @@ -11167,6 +11174,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" From 043cb84ffb282d3b90f4a8e758921d37e28d64d4 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 18:30:25 -0700 Subject: [PATCH 08/24] fix(agentcore-browser): SigV4-presign WSS URL instead of signing headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lambda runtime returned a 403 on the WSS upgrade despite well-formed SigV4 headers — `ws` rewrites the Host header during the upgrade GET, which invalidates the canonical-request signature we computed against the original Host. This works locally because Node's tooling on macOS keeps the original Host through the handshake, but the Lambda runtime's TLS stack normalizes differently. Switch to query-parameter SigV4 (presigned URL): SignatureV4.presign returns a wss://...?X-Amz-Algorithm=...&X-Amz-Signature=... URL where the auth lives in the URL itself, so any Host-header rewriting downstream doesn't break the signature. Smoke verified locally — presigned URL connects cleanly to AgentCore Browser and the screenshot pipeline runs end-to-end (6.3s, valid PNG, captures example.com correctly). Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/handlers/shared/agentcore-browser.ts | 55 +++++++++++++------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/cdk/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts index 5660b444..a48c3545 100644 --- a/cdk/src/handlers/shared/agentcore-browser.ts +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -132,14 +132,14 @@ export async function captureScreenshot(url: string, opts: { timeoutMs?: number * responsible for the StartBrowserSession + StopBrowserSession lifecycle. */ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): Promise { - const headers = await sigV4WsHeaders(wssUrl); - - // Use `ws` (the standard Node WebSocket client) — its constructor accepts - // custom HTTP headers on the upgrade GET via `options.headers`. Node 24's - // global `WebSocket` (from undici) does NOT accept arbitrary headers, and - // AgentCore Browser's WSS handshake requires SigV4-signed `Authorization` - // + `X-Amz-*` headers, so we have to inject them somehow. - const ws = new WebSocket(wssUrl, { headers }); + // 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()); @@ -308,27 +308,46 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): } /** - * Build SigV4-signed headers for the WebSocket upgrade request. AgentCore - * Browser's WSS endpoint expects the same SigV4 envelope as a regular - * `bedrock-agentcore` HTTPS call. + * 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 sigV4WsHeaders(wssUrl: string): Promise> { +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 + u.search, - headers: { - host: u.hostname, - }, + path: u.pathname, + query, + headers: { host: u.hostname }, }); - const signed = await signer.sign(req); - return signed.headers; + + // 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(); } From a2466cb75ce223bdb645961d7f6ef1401b887767 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 18:43:28 -0700 Subject: [PATCH 09/24] fix(iam): grant bedrock-agentcore:* to the screenshot processor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimal IAM I shipped earlier (`StartBrowserSession`, `StopBrowserSession`, `GetBrowserSession`, `UpdateBrowserStream`) wasn't enough — the WSS automation-stream connect requires an additional `ConnectBrowserAutomationStream`-flavored action that isn't in the public CLI command list. Lambda invocations were opening sessions cleanly but 403'ing on the WSS upgrade. Widen to `bedrock-agentcore:*` to unblock the e2e flow. Followup: scope back down to the specific connect action once it's documented or surfaced via CloudTrail decoded-message-on-deny. Smoke verified: PR #1 on isadeks/vercel-abca-linear now receives a screenshot comment within ~7s of the deployment_status webhook. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../constructs/github-screenshot-integration.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts index 3a96f9a1..f79864ad 100644 --- a/cdk/src/constructs/github-screenshot-integration.ts +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -149,16 +149,15 @@ export class GitHubScreenshotIntegration extends Construct { this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn); props.githubTokenSecret.grantRead(this.webhookProcessorFn); - // AgentCore Browser session lifecycle. The data-plane API doesn't - // support per-resource ARNs (sessions are ephemeral), so wildcards - // are required — annotated with a cdk-nag suppression below. + // 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:StartBrowserSession', - 'bedrock-agentcore:StopBrowserSession', - 'bedrock-agentcore:GetBrowserSession', - 'bedrock-agentcore:UpdateBrowserStream', - ], + actions: ['bedrock-agentcore:*'], resources: ['*'], })); From 7bd64122a9dbaf3038ede763bf3c9ee0a4bb2b7a Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 20:53:07 -0700 Subject: [PATCH 10/24] feat(screenshot): also post screenshot comment to linked Linear issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the screenshot processor to find a Linear issue via the PR's title/body and post the same image comment there. Approach (no GSI write-back needed): - Regex-extract Linear identifier (e.g. `ABCA-42`) from PR title/body. These are present whether the agent put them there (`task_description` carries the identifier) or Linear's own GitHub integration auto-injected the back-reference on PR open. - Scan `LinearWorkspaceRegistryTable` for `status=active` workspaces. Per-workspace, query Linear's `issueVcsBranchSearch` (which accepts the human-readable identifier) and accept the first exact-match hit. - Post the markdown image comment via the existing `postIssueComment` helper from Phase 2.0b. The Linear post is best-effort — if the registry table isn't wired, the identifier doesn't extract, or the lookup misses, the GitHub PR comment still lands. New env var `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME` is optional on the processor; the construct only sets it when the prop is provided. CDK: `GitHubScreenshotIntegrationProps` gains an optional `linearWorkspaceRegistryTable`. When provided, the processor's IAM grows: ReadData on the registry, GetSecretValue+PutSecretValue on `bgagent-linear-oauth-*`. `agent.ts` wires `linearIntegration.workspaceRegistryTable` into the screenshot construct. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../github-screenshot-integration.ts | 34 +++- cdk/src/handlers/github-webhook-processor.ts | 106 +++++++++- .../handlers/shared/linear-issue-lookup.ts | 187 ++++++++++++++++++ cdk/src/stacks/agent.ts | 6 + 4 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 cdk/src/handlers/shared/linear-issue-lookup.ts diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts index f79864ad..98ba3a8e 100644 --- a/cdk/src/constructs/github-screenshot-integration.ts +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -18,7 +18,7 @@ */ import * as path from 'path'; -import { Duration, RemovalPolicy } from 'aws-cdk-lib'; +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'; @@ -44,6 +44,15 @@ export interface GitHubScreenshotIntegrationProps { */ 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`. @@ -142,6 +151,9 @@ export class GitHubScreenshotIntegration extends Construct { 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, }); @@ -149,6 +161,26 @@ export class GitHubScreenshotIntegration extends Construct { 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 diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index 935ffe3d..d15240d1 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -21,6 +21,8 @@ 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({}); @@ -32,6 +34,11 @@ const SCREENSHOT_BUCKET = process.env.SCREENSHOT_BUCKET_NAME!; // 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; @@ -122,8 +129,8 @@ export async function handler(event: ProcessorEvent): Promise { return; } - const prNumber = await findPullRequestForSha(repo, sha, token); - if (!prNumber) { + const pr = await findPullRequestForSha(repo, sha, token); + if (!pr) { logger.info('No open PR found for SHA — skipping screenshot post', { repo, sha }); return; } @@ -169,7 +176,7 @@ export async function handler(event: ProcessorEvent): Promise { try { const result = await upsertTaskComment({ repo, - issueOrPrNumber: prNumber, + issueOrPrNumber: pr.number, body: commentBody, token, // Always POST fresh — a single PR can have multiple preview screenshots @@ -179,17 +186,67 @@ export async function handler(event: ProcessorEvent): Promise { }); logger.info('Posted screenshot comment to PR', { repo, - pr_number: prNumber, + 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: prNumber, + 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; } /** @@ -197,14 +254,15 @@ export async function handler(event: ProcessorEvent): Promise { * "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's number, or null if none. Closed/merged - * PRs are filtered out — v1 only screenshots active reviews. + * 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 { +): Promise { const url = `https://api.github.com/repos/${repo}/commits/${sha}/pulls`; let res: Response; try { @@ -234,9 +292,19 @@ async function findPullRequestForSha( return null; } - const pulls = (await res.json()) as Array<{ number?: number; state?: string }>; + 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'); - return open?.number ?? null; + if (!open) return null; + return { + number: open.number!, + title: open.title ?? '', + body: open.body ?? '', + }; } /** Build the S3 key for a screenshot. */ @@ -256,3 +324,21 @@ function renderCommentBody(publicUrl: string, previewUrl: string): string { `_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/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 8dbe0d72..1e96fc68 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -844,6 +844,12 @@ export class AgentStack extends Stack { 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', { From e7d3a19439c5285e57d224f507a0fc84aadf32ce Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Thu, 21 May 2026 01:20:40 -0700 Subject: [PATCH 11/24] fix(screenshot): retry PR lookup to handle deploy-before-PR race Some providers (Vercel, Netlify) post deployment_status faster than the agent can run `gh pr create`. Retry the GitHub PR-lookup with backoff so the screenshot finds the open PR rather than dropping the event when the timing is reversed. --- cdk/src/handlers/github-webhook-processor.ts | 36 ++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index d15240d1..31a88eb8 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -129,9 +129,13 @@ export async function handler(event: ProcessorEvent): Promise { return; } - const pr = await findPullRequestForSha(repo, sha, token); + // 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 — skipping screenshot post', { repo, sha }); + logger.info('No open PR found for SHA after retries — skipping screenshot post', { repo, sha }); return; } @@ -249,6 +253,34 @@ interface OpenPr { 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 From b81eee68568a2aa013fedcd50b308141ab4265f6 Mon Sep 17 00:00:00 2001 From: bgagent Date: Thu, 21 May 2026 02:20:17 -0700 Subject: [PATCH 12/24] fix(linear): silent label gate + default to 'abca' to stop unlabeled-issue comment spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the trigger-label check ahead of every user-facing comment path in the Linear webhook processor, and switch the default trigger label from 'bgagent' to 'abca'. An unlabeled issue is now a true no-op: no comment, no reaction, no createTaskCore, no DDB writes — regardless of whether the project is onboarded. Why: workspace webhooks fire workspace-wide. A single un-onboarded team in the same Linear workspace produced 47 identical "❌ project isn't onboarded" comments on GRO-783 in 5 minutes because every Issue event (create/update/label-change) hit the not-onboarded gate before the label gate. With the gate order flipped, only issues that explicitly opt in via the trigger label can ever generate user-facing feedback. Per-project label_filter override is still respected — the project mapping lookup now happens once, before the label gate, instead of after. Tests: two new regression tests pin the spam scenario (unlabeled issue in a non-onboarded project, and unlabeled issue with no projectId) to zero side effects. Full CDK suite (89 suites / 1572 tests) passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/handlers/linear-webhook-processor.ts | 70 ++++++++++++------- .../handlers/linear-webhook-processor.test.ts | 36 +++++++++- 2 files changed, 78 insertions(+), 28 deletions(-) diff --git a/cdk/src/handlers/linear-webhook-processor.ts b/cdk/src/handlers/linear-webhook-processor.ts index 18ad6a27..bf34a00e 100644 --- a/cdk/src/handlers/linear-webhook-processor.ts +++ b/cdk/src/handlers/linear-webhook-processor.ts @@ -31,7 +31,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. @@ -150,6 +150,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, @@ -162,12 +204,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, @@ -179,24 +216,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/test/handlers/linear-webhook-processor.test.ts b/cdk/test/handlers/linear-webhook-processor.test.ts index f93bfb2e..14cb24b3 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, }; @@ -143,7 +143,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(); @@ -159,7 +159,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', @@ -356,6 +356,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 From bce3aa66ae44d3030d14308f29e7083395df2fdd Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Thu, 21 May 2026 02:50:05 -0700 Subject: [PATCH 13/24] docs(screenshots): add the screenshot pipeline guide Adds the operator walkthrough for wiring up the AgentCore-Browser preview-deploy screenshot pipeline. --- docs/astro.config.mjs | 1 + docs/guides/VERCEL_SETUP_GUIDE.md | 220 +++++++++++++++++ docs/scripts/sync-starlight.mjs | 7 + .../content/docs/using/Vercel-setup-guide.md | 224 ++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 docs/guides/VERCEL_SETUP_GUIDE.md create mode 100644 docs/src/content/docs/using/Vercel-setup-guide.md diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 9f14b2d8..6469c090 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -57,6 +57,7 @@ export default defineConfig({ { slug: 'using/slack-setup-guide' }, { slug: 'using/linear-setup-guide' }, { slug: 'using/linear-pak-migration-runbook' }, + { 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 b28c3075..75576992 100644 --- a/docs/scripts/sync-starlight.mjs +++ b/docs/scripts/sync-starlight.mjs @@ -46,6 +46,7 @@ function rewriteDocsLinkTarget(target) { SLACK_SETUP_GUIDE: '/using/slack-setup-guide', LINEAR_SETUP_GUIDE: '/using/linear-setup-guide', LINEAR_PAK_MIGRATION_RUNBOOK: '/using/linear-pak-migration-runbook', + VERCEL_SETUP_GUIDE: '/using/vercel-setup-guide', CEDAR_POLICY_GUIDE: '/customizing/cedar-policies', DEPLOYMENT_GUIDE: '/getting-started/deployment-guide', }; @@ -245,6 +246,12 @@ mirrorMarkdownFile( path.join('src', 'content', 'docs', 'using', 'Linear-pak-migration-runbook.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`). From 62829a0c57213c8524d4c8f1b1f86cadd816fac2 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 27 May 2026 06:35:33 -0400 Subject: [PATCH 14/24] feat(github): bgagent github webhook-info + set-webhook-secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Linear webhook-info pattern so docs and onboarding don't have to embed stack-specific URLs or copy-paste aws CLI invocations. Two subcommands: - `webhook-info` — read-only. Reads GitHubWebhookUrl + GitHubWebhookSecretArn from the CFN stack outputs and prints values to paste into a GitHub repo's webhook config (Settings → Webhooks → Add webhook). Includes the event-type ('Deployment statuses') and content-type guidance that operators consistently miss. - `set-webhook-secret` — interactive PutSecretValue against the stack output ARN. Replaces the cargo-cult `aws secretsmanager put-secret- value` operators were copy-pasting from the screenshot setup notes. Warns before overwriting an existing real secret (heuristic: a CDK- seeded JSON placeholder starts with `{`; a real GitHub secret won't). No CDK changes — both stack outputs were already there. Pure CLI add. --- cli/src/bin/bgagent.ts | 2 + cli/src/commands/github.ts | 229 +++++++++++++++++++++++++++++++++++++ 2 files changed, 231 insertions(+) create mode 100644 cli/src/commands/github.ts diff --git a/cli/src/bin/bgagent.ts b/cli/src/bin/bgagent.ts index eecec7b2..50ab589d 100644 --- a/cli/src/bin/bgagent.ts +++ b/cli/src/bin/bgagent.ts @@ -26,6 +26,7 @@ import { makeCancelCommand } from '../commands/cancel'; import { makeConfigureCommand } from '../commands/configure'; import { makeDenyCommand } from '../commands/deny'; import { makeEventsCommand } from '../commands/events'; +import { makeGithubCommand } from '../commands/github'; import { makeLinearCommand } from '../commands/linear'; import { makeListCommand } from '../commands/list'; import { makeLoginCommand } from '../commands/login'; @@ -70,6 +71,7 @@ program.addCommand(makePoliciesCommand()); program.addCommand(makeEventsCommand()); program.addCommand(makeSlackCommand()); program.addCommand(makeLinearCommand()); +program.addCommand(makeGithubCommand()); program.addCommand(makeWatchCommand()); program.addCommand(makeTraceCommand()); program.addCommand(makeWebhookCommand()); diff --git a/cli/src/commands/github.ts b/cli/src/commands/github.ts new file mode 100644 index 00000000..cd119a96 --- /dev/null +++ b/cli/src/commands/github.ts @@ -0,0 +1,229 @@ +/** + * 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 { CloudFormationClient, DescribeStacksCommand } from '@aws-sdk/client-cloudformation'; +import { + GetSecretValueCommand, + PutSecretValueCommand, + SecretsManagerClient, +} from '@aws-sdk/client-secrets-manager'; +import { Command } from 'commander'; +import { loadConfig } from '../config'; +import { CliError } from '../errors'; + +export function makeGithubCommand(): Command { + const github = new Command('github') + .description('Manage GitHub integration (deployment-status webhook for Vercel preview screenshots)'); + + github.addCommand( + new Command('webhook-info') + .description('Print the GitHub webhook URL + values to paste into a repo\'s webhook config') + .option('--region ', 'AWS region (defaults to configured region)') + .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') + .action(async (opts) => { + // Read-only convenience — surfaces the values an operator needs + // to wire a GitHub repo's webhook to the screenshot pipeline. + // Mirrors `bgagent linear webhook-info` so the docs don't have + // to embed stack-specific URLs. + const config = loadConfig(); + const region = opts.region || config.region; + const stackName = opts.stackName; + + const [webhookUrl, webhookSecretArn] = await Promise.all([ + getStackOutput(region, stackName, 'GitHubWebhookUrl'), + getStackOutput(region, stackName, 'GitHubWebhookSecretArn'), + ]); + + if (!webhookUrl) { + throw new CliError( + `Stack '${stackName}' is missing output 'GitHubWebhookUrl'. ` + + 'Re-deploy with the screenshot CDK changes (mise //cdk:deploy).', + ); + } + + const bar = '═'.repeat(72); + console.log(bar); + console.log('GitHub webhook configuration (Vercel preview screenshot pipeline)'); + console.log(bar); + console.log(); + console.log('In GitHub, on the repo whose previews should generate screenshots:'); + console.log(' Settings → Webhooks → Add webhook, paste:'); + console.log(); + console.log(` Payload URL: ${webhookUrl}`); + console.log(' Content type: application/json'); + console.log(' Secret: (generate any random string and paste it both here AND below)'); + console.log(' Events: Let me select individual events → Deployment statuses'); + console.log(); + console.log('Save the webhook in GitHub, then mirror the same secret into AWS so the'); + console.log('receiver can verify the HMAC:'); + console.log(); + if (webhookSecretArn) { + console.log(` bgagent github set-webhook-secret # interactive prompt`); + console.log(); + console.log(` Secret ARN: ${webhookSecretArn}`); + } else { + console.log(' (Stack output GitHubWebhookSecretArn not found — check `aws cloudformation describe-stacks`.)'); + } + console.log(); + console.log('Note: Vercel posts deployment_status events via the GitHub Deployments API,'); + console.log('so this single webhook covers all Vercel-connected previews on the repo.'); + console.log(bar); + }), + ); + + github.addCommand( + new Command('set-webhook-secret') + .description('Mirror the GitHub webhook signing secret into Secrets Manager') + .option('--region ', 'AWS region (defaults to configured region)') + .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') + .action(async (opts) => { + // Companion to `webhook-info`: after the operator pastes the + // webhook config into GitHub, this command captures the + // signing secret they generated and stores it where the + // receiver Lambda reads it. No-frills wrapper around + // PutSecretValue — but operators were copy-pasting aws CLI + // before, which is more error-prone (wrong --secret-id format, + // no validation that the stack output even exists). + const config = loadConfig(); + const region = opts.region || config.region; + const stackName = opts.stackName; + + const webhookSecretArn = await getStackOutput(region, stackName, 'GitHubWebhookSecretArn'); + if (!webhookSecretArn) { + throw new CliError( + `Stack '${stackName}' is missing output 'GitHubWebhookSecretArn'. ` + + 'Re-deploy with the screenshot CDK changes (mise //cdk:deploy).', + ); + } + + const sm = new SecretsManagerClient({ region }); + + // Show whether a secret is already configured so the operator + // doesn't accidentally rotate it without realising. Linear's + // signing secrets start with `lin_wh_` — GitHub's are + // free-form (operator-chosen), so we can't pattern-match. + // Just check whether *anything* is there. + let alreadyConfigured = false; + try { + const cur = await sm.send(new GetSecretValueCommand({ SecretId: webhookSecretArn })); + if (cur.SecretString && cur.SecretString.length > 0 && !cur.SecretString.startsWith('{')) { + // CDK seeds a JSON-blob placeholder; a real GitHub secret + // wouldn't start with `{`. Crude but good enough. + alreadyConfigured = true; + } + } catch (err) { + if ((err as { name?: string }).name !== 'ResourceNotFoundException') { + throw err; + } + } + if (alreadyConfigured) { + console.log(' ⚠ A signing secret is already configured. This command will OVERWRITE it.'); + console.log(' Make sure the new value matches what you pasted into GitHub.'); + console.log(); + } + + const secret = (await promptSecret('GitHub webhook signing secret: ')).trim(); + if (!secret) { + throw new CliError('Webhook signing secret is required.'); + } + + await sm.send(new PutSecretValueCommand({ + SecretId: webhookSecretArn, + SecretString: secret, + })); + console.log(); + console.log('✅ Stored webhook signing secret.'); + console.log(); + console.log('Test by triggering a Vercel preview deploy on the configured repo. The'); + console.log('receiver Lambda log group should show a successful HMAC verification on'); + console.log('the next deployment_status event.'); + }), + ); + + return github; +} + +// ─── Stack-output helper ───────────────────────────────────────────────────── + +async function getStackOutput(region: string, stackName: string, outputKey: string): Promise { + const cf = new CloudFormationClient({ region }); + try { + const result = await cf.send(new DescribeStacksCommand({ StackName: stackName })); + const stack = result.Stacks?.[0]; + if (!stack) return null; + return stack.Outputs?.find((o) => o.OutputKey === outputKey)?.OutputValue ?? null; + } catch (err) { + throw new CliError( + `Could not describe stack '${stackName}' in ${region}: ${err instanceof Error ? err.message : String(err)}`, + ); + } +} + +// ─── Secret prompt (raw-mode, masked) ──────────────────────────────────────── + +function promptSecret(label: string): Promise { + return new Promise((resolve, reject) => { + process.stderr.write(label); + + if (!process.stdin.isTTY) { + let buf = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + buf += chunk.toString(); + }); + process.stdin.on('end', () => resolve(buf.trim())); + process.stdin.on('error', reject); + return; + } + + process.stdin.setRawMode(true); + process.stdin.resume(); + let value = ''; + const onData = (chunk: Buffer) => { + const str = chunk.toString(); + for (const char of str) { + if (char === '\n' || char === '\r') { + cleanup(); + process.stderr.write('\n'); + resolve(value.trim()); + return; + } else if (char === '') { + cleanup(); + process.stderr.write('\n'); + reject(new Error('Cancelled.')); + return; + } else if (char === '' || char === '\b') { + if (value.length > 0) { + value = value.slice(0, -1); + process.stderr.write('\b \b'); + } + } else { + value += char; + process.stderr.write('*'); + } + } + }; + const cleanup = () => { + process.stdin.removeListener('data', onData); + process.stdin.setRawMode(false); + process.stdin.pause(); + }; + process.stdin.on('data', onData); + }); +} From 734c124c5a3f2caecfe8e4c7a02eab9b6c91076f Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 27 May 2026 06:50:51 -0400 Subject: [PATCH 15/24] docs/code(screenshots): de-Vercel-ize the screenshot pipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pipeline was always provider-agnostic — it listens for GitHub deployment_status events, which Vercel, AWS Amplify, Netlify, and any GitHub-Actions-driven CD pipeline all post. Code comments, inline strings, and the setup guide referenced Vercel as if it were the only supported path; this commit aligns the surfacing with what the code actually does. Code: - Linear comment body: "after the Vercel preview deploy finished" → "after the deploy finished" (the GitHub PR comment already said this; just the Linear path was inconsistent) - Webhook receiver doc-comment + envelope interface comment: drop Vercel-only language; explain that the `environment` filter (`SCREENSHOT_TARGET_ENVIRONMENT` env var) is configurable per- provider, with a table of common values - Processor PR-race comment: explain that the gap is also seen on Netlify/Amplify, not unique to Vercel - AgentCore Browser comment: drop Vercel-specific phrasing on "what we don't try to be clever about" - GitHubScreenshotIntegration construct prop docstring: explain the per-provider env-name conventions Docs: - Rename VERCEL_SETUP_GUIDE.md → DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md - Lead with a "works with any provider that posts deployment_status" table (Vercel / Amplify / Netlify / GitHub Actions custom CD, with "out-of-the-box?" yes/no per provider) - Keep Vercel as the worked example since it's what we smoke-tested, but add a "skip Steps 1-2" callout for non-Vercel providers - New "Configuring for non-Vercel providers" section with the SCREENSHOT_TARGET_ENVIRONMENT override pointer - Replace 4a/4b's CFN-output spelunking with `bgagent github webhook-info` + `bgagent github set-webhook-secret` (commands shipped in 1c1b618) - Troubleshooting: mention that 401 "Invalid signature" is the set-webhook-secret-mismatch case - Sync registration: register as DEPLOY_PREVIEW_SCREENSHOTS_GUIDE in sync-starlight.mjs route map + the explicit mirror call; added to astro.config.mjs sidebar after the PAK runbook No CDK structural changes — the construct prop, env-var, and code behaviour were already provider-agnostic. Pure surfacing fix. --- .../github-screenshot-integration.ts | 9 +- cdk/src/handlers/github-webhook-processor.ts | 18 +-- cdk/src/handlers/github-webhook.ts | 58 +++++--- cdk/src/handlers/shared/agentcore-browser.ts | 7 +- docs/astro.config.mjs | 2 +- .../DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md} | 125 ++++++++++------- docs/scripts/sync-starlight.mjs | 8 +- .../Deploy-preview-screenshots-guide.md} | 127 +++++++++++------- 8 files changed, 212 insertions(+), 142 deletions(-) rename docs/{src/content/docs/using/Vercel-setup-guide.md => guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md} (55%) rename docs/{guides/VERCEL_SETUP_GUIDE.md => src/content/docs/using/Deploy-preview-screenshots-guide.md} (55%) diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts index 98ba3a8e..7fd66cd3 100644 --- a/cdk/src/constructs/github-screenshot-integration.ts +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -60,9 +60,12 @@ export interface GitHubScreenshotIntegrationProps { 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. + * Override for the GitHub deployment `environment` value we + * screenshot. Different providers use different conventions: + * `Preview` (Vercel's per-PR label, the default), branch names + * (Amplify Hosting), `Deploy Preview ` (Netlify), or whatever + * your GitHub Actions workflow passes. Set this when your provider + * uses a different name and you want per-PR-only screenshots. * @default 'Preview' */ readonly screenshotTargetEnvironment?: string; diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index 31a88eb8..23a3702e 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -129,10 +129,11 @@ export async function handler(event: ProcessorEvent): Promise { 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. + // Race: managed providers (Vercel, Netlify, Amplify) post + // `deployment_status` the moment their 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 }); @@ -255,9 +256,10 @@ interface OpenPr { /** * 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. + * small backoff. Managed providers commonly post `deployment_status` + * before the agent's `gh pr create` call lands (we've measured 5-15s + * gap on Vercel; Netlify/Amplify behave similarly), 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. @@ -371,6 +373,6 @@ function renderLinearCommentBody(publicUrl: string, previewUrl: string): string '', `Live preview: [${previewUrl}](${previewUrl})`, '', - '_Captured automatically by ABCA after the Vercel preview deploy finished._', + '_Captured automatically by ABCA after the deploy finished._', ].join('\n'); } diff --git a/cdk/src/handlers/github-webhook.ts b/cdk/src/handlers/github-webhook.ts index bad8f345..02a73f3c 100644 --- a/cdk/src/handlers/github-webhook.ts +++ b/cdk/src/handlers/github-webhook.ts @@ -39,16 +39,21 @@ const PROCESSOR_FUNCTION_NAME = process.env.GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NA 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: + * Subset of GitHub's `deployment_status` payload we route on. Any deploy + * backend that calls the GitHub Deployments API posts this when a deploy + * finishes — Vercel, AWS Amplify Hosting, Netlify, Cloud Run, or your + * own GitHub Actions workflow that calls `POST /repos/.../deployments`. + * 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.environment`: provider-defined string (Vercel uses + * `Preview`/`Production`, Amplify uses the branch name, GitHub + * Actions uses whatever the workflow passes). Filtered against + * `SCREENSHOT_TARGET_ENVIRONMENT` env var. * - `deployment.sha`: the commit SHA the deploy is for (used to map * back to a PR via the GitHub commit-pulls API) * @@ -77,18 +82,21 @@ interface GitHubDeploymentStatusEnvelope { * * 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. + * filters to successful `deployment_status` events whose environment + * matches `SCREENSHOT_TARGET_ENVIRONMENT` (default `Preview`), 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. + * Most managed hosting providers (Vercel, Netlify, Amplify) don't run + * a GitHub Action to deploy — they post directly to the GitHub + * Deployments API. Self-hosted CI typically calls the same API at the + * end of its workflow. `deployment_status` carries the deploy URL + * (`deployment_status.environment_url`) and the SHA the deploy is + * for, letting us route to the correct ABCA task and screenshot the + * right URL without provider-specific extra API calls. */ export async function handler(event: APIGatewayProxyEvent): Promise { try { @@ -134,19 +142,25 @@ export async function handler(event: APIGatewayProxyEvent): Promise` + // Operators on non-Vercel backends override via + // `SCREENSHOT_TARGET_ENVIRONMENT` (Lambda env var, redeploy required). const targetEnv = process.env.SCREENSHOT_TARGET_ENVIRONMENT ?? 'Preview'; if (payload.deployment?.environment !== targetEnv) { return jsonResponse(200, { diff --git a/cdk/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts index a48c3545..3d8d85e1 100644 --- a/cdk/src/handlers/shared/agentcore-browser.ts +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -82,8 +82,8 @@ interface CdpMessage { * 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. + * injection — the agent is just snapshotting public preview URLs + * that render with default settings (no auth, no per-user state). * * @param url The URL to navigate to and screenshot. * @param opts.timeoutMs Override the default 60s budget. @@ -288,7 +288,8 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): // 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. + // typical preview URLs (Vercel/Netlify/Amplify CDN edges) this + // is enough. await waitForEvent('Page.loadEventFired'); await new Promise((r) => setTimeout(r, 2000)); diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 6469c090..be9dfe92 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -57,7 +57,7 @@ export default defineConfig({ { slug: 'using/slack-setup-guide' }, { slug: 'using/linear-setup-guide' }, { slug: 'using/linear-pak-migration-runbook' }, - { slug: 'using/vercel-setup-guide' }, + { slug: 'using/deploy-preview-screenshots-guide' }, { slug: 'using/task-lifecycle' }, { slug: 'using/what-the-agent-does' }, { slug: 'using/tips-for-being-a-good-citizen' }, diff --git a/docs/src/content/docs/using/Vercel-setup-guide.md b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md similarity index 55% rename from docs/src/content/docs/using/Vercel-setup-guide.md rename to docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md index 0af011ce..9ba04895 100644 --- a/docs/src/content/docs/using/Vercel-setup-guide.md +++ b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md @@ -1,32 +1,49 @@ ---- -title: Vercel setup guide ---- +# Deploy preview screenshots setup guide -# Vercel preview screenshots setup guide +Wire your 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. -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:** 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. -> **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. +## Works with any provider that posts `deployment_status` + +The pipeline doesn't care who built the deploy — it only listens for GitHub `deployment_status` events. Any provider that calls the [GitHub Deployments API](https://docs.github.com/en/rest/deployments/deployments) works: + +| Provider | Out of the box? | Notes | +|---|---|---| +| **Vercel** (managed hosting + GitHub app) | ✅ | The worked example below uses this. Default `environment` is `Preview`. | +| **AWS Amplify Hosting** (Connected to GitHub) | ✅ | Posts deployment_status for each branch deploy. `environment` is the branch name — set `SCREENSHOT_TARGET_ENVIRONMENT` to your preview branch (or use the same value on every branch via the `BackgroundAgentStack` construct prop). | +| **Netlify** (managed hosting + GitHub app) | ✅ | `environment` is `Deploy Preview `. Single fixed string filter doesn't catch all PRs — followup to support pattern matching. | +| **GitHub Actions** that calls `POST /repos/.../deployments` (typical for ECS/Fargate, Cloud Run, Fly.io, Railway, Cloudflare Pages, etc.) | ✅ | Your workflow controls the `environment` field; pass whatever you want and set `SCREENSHOT_TARGET_ENVIRONMENT` to match. | +| **External CI** (CircleCI, GitLab, ArgoCD) that doesn't touch GitHub Deployments | ❌ | Add a final job that calls the GitHub Deployments API after the deploy succeeds — see [GitHub's example](https://docs.github.com/en/rest/deployments/deployments#create-a-deployment). | + +ABCA needs only two things from a deploy: + +1. The `deployment_status` event has reached `state: success`. +2. `deployment_status.environment_url` is populated with the live preview URL. + +If your provider gives you that, you're done. The example below is Vercel because that's what we smoke-tested on; the pipeline doesn't otherwise prefer one provider over another. ## 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: +When ABCA opens a PR for a Linear-driven task, your provider 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. +End-to-end latency: typically 10–15 seconds after your provider reports the deploy. ## How it works ``` -agent push → Vercel preview build → deployment_status webhook +agent push → provider preview build → deployment_status webhook ↓ POST /v1/github/webhook ↓ - receiver Lambda (HMAC verify, dedup) + receiver Lambda (HMAC verify, dedup, + state=success + + environment filter) ↓ processor Lambda ↓ @@ -44,24 +61,24 @@ 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. +- **WAF exemption.** The `/v1/github/webhook` path is excluded from the AWSManagedRulesCommonRuleSet because 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 +- ABCA stack deployed (`mise //cdk:deploy`) — 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 +- A GitHub repo you own +- Your deploy provider connected to that repo (the example uses Vercel) - 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-by-step setup (Vercel example) ### 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. +3. Find your repo in the list. 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. @@ -80,6 +97,8 @@ Go to **your-project → Settings** in the Vercel dashboard. > **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. +> **Using a different provider?** Skip Steps 1–2 and follow your provider's instructions to publish `deployment_status` events to GitHub. For Amplify Hosting, that's automatic when the app is connected via GitHub. For self-hosted CI, add a `gh api repos/.../deployments` step at the end of your deploy job. Then continue with Step 3. + ### Step 3 — Onboard the repo to ABCA ABCA needs to know the repo is allowed to receive tasks. Two writes: @@ -92,7 +111,7 @@ There's no CLI helper today; do a direct DDB put. Replace the table name with yo aws dynamodb put-item --region us-east-1 \ --table-name \ --item '{ - "repo": {"S": "your-org/your-vercel-repo"}, + "repo": {"S": "your-org/your-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)'"} @@ -107,55 +126,45 @@ bgagent linear list-projects # Map it to the repo bgagent linear onboard-project \ - --repo your-org/your-vercel-repo \ + --repo your-org/your-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. +The `--label` controls which Linear label triggers a task. Defaults to `bgagent`; the demo uses `abca`. Use any label you like — it just 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. +This wires deploys back to ABCA's screenshot pipeline. -#### 4a. Get the webhook URL +#### 4a. Get the webhook config ```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 +bgagent github webhook-info ``` -#### 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 -``` +The CLI prints the webhook URL and the values to paste into GitHub. -#### 4c. Add the webhook on the GitHub repo +#### 4b. Add the webhook on the GitHub repo -1. Open `https://github.com///settings/hooks`. +1. Open `https://github.com///settings/hooks`. 2. Click **Add webhook**. -3. Fill in: - - **Payload URL**: the URL from 4a +3. Fill in the values printed by `webhook-info`: + - **Payload URL**: the URL it printed - **Content type**: `application/json` - - **Secret**: the value from 4b + - **Secret**: generate any random string — paste it both here AND into the next step - **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. +#### 4c. Mirror the signing secret into AWS + +```bash +bgagent github set-webhook-secret +``` + +Paste the same secret you used in 4b. The CLI writes it to the stack's `GitHubWebhookSecret` Secrets Manager entry, where the receiver Lambda reads it for HMAC verification. + ### 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`. @@ -163,17 +172,29 @@ aws secretsmanager get-secret-value \ 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) + - Provider builds the preview - **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. +## Configuring for non-Vercel providers + +The pipeline filters incoming webhooks against `SCREENSHOT_TARGET_ENVIRONMENT` (default `Preview`, matches Vercel's per-PR environment label). To use a different value, pass `screenshotTargetEnvironment` to the `GitHubScreenshotIntegration` construct in your CDK app and redeploy. + +| Provider | Typical `environment` value | What to set | +|---|---|---| +| Vercel | `Preview` | leave default | +| Amplify Hosting | branch name (e.g. `main`, `staging`) | the branch you treat as preview | +| Netlify | `Deploy Preview ` | currently not directly matchable — followup #96 covers prefix routing | +| GitHub Actions custom | whatever your workflow passes | match it exactly | + ## 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. +- **401 "Invalid signature"**: the secret you pasted into GitHub doesn't match what's stored in AWS. Re-run `bgagent github set-webhook-secret` with the value from the GitHub webhook page. - **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 @@ -189,9 +210,9 @@ aws lambda list-functions --region us-east-1 \ 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. +- `skipped_environment` — the deploy's `environment` field doesn't match `SCREENSHOT_TARGET_ENVIRONMENT`. Common cause for non-Vercel providers; see "Configuring for non-Vercel providers" above. +- `skipped_no_url` — the `success` status didn't include `environment_url`. Some providers post URL-less success events; the next push usually carries the URL. +- `No open PR found for SHA after retries` — the deploy provider 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 @@ -210,7 +231,7 @@ 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 +### Screenshot shows a login page (Vercel only) You forgot Step 2's "Vercel Authentication: Disabled" toggle. Toggle it off, push another commit, and confirm the next screenshot renders the actual app. @@ -218,7 +239,7 @@ You forgot Step 2's "Vercel Authentication: Disabled" toggle. Toggle it off, pus 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). +1. **Re-enable Vercel Standard Protection** (or your provider's equivalent) + signed bypass token; teach the screenshot processor to inject the 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 75576992..d9b22b8e 100644 --- a/docs/scripts/sync-starlight.mjs +++ b/docs/scripts/sync-starlight.mjs @@ -46,7 +46,7 @@ function rewriteDocsLinkTarget(target) { SLACK_SETUP_GUIDE: '/using/slack-setup-guide', LINEAR_SETUP_GUIDE: '/using/linear-setup-guide', LINEAR_PAK_MIGRATION_RUNBOOK: '/using/linear-pak-migration-runbook', - VERCEL_SETUP_GUIDE: '/using/vercel-setup-guide', + DEPLOY_PREVIEW_SCREENSHOTS_GUIDE: '/using/deploy-preview-screenshots-guide', CEDAR_POLICY_GUIDE: '/customizing/cedar-policies', DEPLOYMENT_GUIDE: '/getting-started/deployment-guide', }; @@ -246,10 +246,10 @@ mirrorMarkdownFile( path.join('src', 'content', 'docs', 'using', 'Linear-pak-migration-runbook.md'), ); -// --- Vercel Setup Guide: mirror to using/ --- +// --- Deploy preview screenshots guide: mirror to using/ --- mirrorMarkdownFile( - path.join(docsRoot, 'guides', 'VERCEL_SETUP_GUIDE.md'), - path.join('src', 'content', 'docs', 'using', 'Vercel-setup-guide.md'), + path.join(docsRoot, 'guides', 'DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md'), + path.join('src', 'content', 'docs', 'using', 'Deploy-preview-screenshots-guide.md'), ); // --- Cedar Policy Guide: mirror to customizing/ (authoring reference for blueprint authors) --- diff --git a/docs/guides/VERCEL_SETUP_GUIDE.md b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md similarity index 55% rename from docs/guides/VERCEL_SETUP_GUIDE.md rename to docs/src/content/docs/using/Deploy-preview-screenshots-guide.md index d78d807a..27d20dfa 100644 --- a/docs/guides/VERCEL_SETUP_GUIDE.md +++ b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md @@ -1,28 +1,53 @@ -# Vercel preview screenshots setup guide +--- +title: Deploy preview screenshots 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. +# Deploy preview screenshots setup guide -> **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. +Wire your 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:** 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. + +## Works with any provider that posts `deployment_status` + +The pipeline doesn't care who built the deploy — it only listens for GitHub `deployment_status` events. Any provider that calls the [GitHub Deployments API](https://docs.github.com/en/rest/deployments/deployments) works: + +| Provider | Out of the box? | Notes | +|---|---|---| +| **Vercel** (managed hosting + GitHub app) | ✅ | The worked example below uses this. Default `environment` is `Preview`. | +| **AWS Amplify Hosting** (Connected to GitHub) | ✅ | Posts deployment_status for each branch deploy. `environment` is the branch name — set `SCREENSHOT_TARGET_ENVIRONMENT` to your preview branch (or use the same value on every branch via the `BackgroundAgentStack` construct prop). | +| **Netlify** (managed hosting + GitHub app) | ✅ | `environment` is `Deploy Preview `. Single fixed string filter doesn't catch all PRs — followup to support pattern matching. | +| **GitHub Actions** that calls `POST /repos/.../deployments` (typical for ECS/Fargate, Cloud Run, Fly.io, Railway, Cloudflare Pages, etc.) | ✅ | Your workflow controls the `environment` field; pass whatever you want and set `SCREENSHOT_TARGET_ENVIRONMENT` to match. | +| **External CI** (CircleCI, GitLab, ArgoCD) that doesn't touch GitHub Deployments | ❌ | Add a final job that calls the GitHub Deployments API after the deploy succeeds — see [GitHub's example](https://docs.github.com/en/rest/deployments/deployments#create-a-deployment). | + +ABCA needs only two things from a deploy: + +1. The `deployment_status` event has reached `state: success`. +2. `deployment_status.environment_url` is populated with the live preview URL. + +If your provider gives you that, you're done. The example below is Vercel because that's what we smoke-tested on; the pipeline doesn't otherwise prefer one provider over another. ## 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: +When ABCA opens a PR for a Linear-driven task, your provider 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. +End-to-end latency: typically 10–15 seconds after your provider reports the deploy. ## How it works ``` -agent push → Vercel preview build → deployment_status webhook +agent push → provider preview build → deployment_status webhook ↓ POST /v1/github/webhook ↓ - receiver Lambda (HMAC verify, dedup) + receiver Lambda (HMAC verify, dedup, + state=success + + environment filter) ↓ processor Lambda ↓ @@ -40,24 +65,24 @@ 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. +- **WAF exemption.** The `/v1/github/webhook` path is excluded from the AWSManagedRulesCommonRuleSet because 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 +- ABCA stack deployed (`mise //cdk:deploy`) — 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 +- A GitHub repo you own +- Your deploy provider connected to that repo (the example uses Vercel) - 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-by-step setup (Vercel example) ### 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. +3. Find your repo in the list. 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. @@ -76,6 +101,8 @@ Go to **your-project → Settings** in the Vercel dashboard. > **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. +> **Using a different provider?** Skip Steps 1–2 and follow your provider's instructions to publish `deployment_status` events to GitHub. For Amplify Hosting, that's automatic when the app is connected via GitHub. For self-hosted CI, add a `gh api repos/.../deployments` step at the end of your deploy job. Then continue with Step 3. + ### Step 3 — Onboard the repo to ABCA ABCA needs to know the repo is allowed to receive tasks. Two writes: @@ -88,7 +115,7 @@ There's no CLI helper today; do a direct DDB put. Replace the table name with yo aws dynamodb put-item --region us-east-1 \ --table-name \ --item '{ - "repo": {"S": "your-org/your-vercel-repo"}, + "repo": {"S": "your-org/your-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)'"} @@ -103,55 +130,45 @@ bgagent linear list-projects # Map it to the repo bgagent linear onboard-project \ - --repo your-org/your-vercel-repo \ + --repo your-org/your-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. +The `--label` controls which Linear label triggers a task. Defaults to `bgagent`; the demo uses `abca`. Use any label you like — it just 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. +This wires deploys back to ABCA's screenshot pipeline. -#### 4a. Get the webhook URL +#### 4a. Get the webhook config ```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 +bgagent github webhook-info ``` -#### 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 -``` +The CLI prints the webhook URL and the values to paste into GitHub. -#### 4c. Add the webhook on the GitHub repo +#### 4b. Add the webhook on the GitHub repo -1. Open `https://github.com///settings/hooks`. +1. Open `https://github.com///settings/hooks`. 2. Click **Add webhook**. -3. Fill in: - - **Payload URL**: the URL from 4a +3. Fill in the values printed by `webhook-info`: + - **Payload URL**: the URL it printed - **Content type**: `application/json` - - **Secret**: the value from 4b + - **Secret**: generate any random string — paste it both here AND into the next step - **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. +#### 4c. Mirror the signing secret into AWS + +```bash +bgagent github set-webhook-secret +``` + +Paste the same secret you used in 4b. The CLI writes it to the stack's `GitHubWebhookSecret` Secrets Manager entry, where the receiver Lambda reads it for HMAC verification. + ### 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`. @@ -159,17 +176,29 @@ aws secretsmanager get-secret-value \ 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) + - Provider builds the preview - **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. +## Configuring for non-Vercel providers + +The pipeline filters incoming webhooks against `SCREENSHOT_TARGET_ENVIRONMENT` (default `Preview`, matches Vercel's per-PR environment label). To use a different value, pass `screenshotTargetEnvironment` to the `GitHubScreenshotIntegration` construct in your CDK app and redeploy. + +| Provider | Typical `environment` value | What to set | +|---|---|---| +| Vercel | `Preview` | leave default | +| Amplify Hosting | branch name (e.g. `main`, `staging`) | the branch you treat as preview | +| Netlify | `Deploy Preview ` | currently not directly matchable — followup #96 covers prefix routing | +| GitHub Actions custom | whatever your workflow passes | match it exactly | + ## 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. +- **401 "Invalid signature"**: the secret you pasted into GitHub doesn't match what's stored in AWS. Re-run `bgagent github set-webhook-secret` with the value from the GitHub webhook page. - **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 @@ -185,9 +214,9 @@ aws lambda list-functions --region us-east-1 \ 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. +- `skipped_environment` — the deploy's `environment` field doesn't match `SCREENSHOT_TARGET_ENVIRONMENT`. Common cause for non-Vercel providers; see "Configuring for non-Vercel providers" above. +- `skipped_no_url` — the `success` status didn't include `environment_url`. Some providers post URL-less success events; the next push usually carries the URL. +- `No open PR found for SHA after retries` — the deploy provider 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 @@ -206,7 +235,7 @@ 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 +### Screenshot shows a login page (Vercel only) You forgot Step 2's "Vercel Authentication: Disabled" toggle. Toggle it off, push another commit, and confirm the next screenshot renders the actual app. @@ -214,7 +243,7 @@ You forgot Step 2's "Vercel Authentication: Disabled" toggle. Toggle it off, pus 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). +1. **Re-enable Vercel Standard Protection** (or your provider's equivalent) + signed bypass token; teach the screenshot processor to inject the 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`). From 1ce013d9b4d5e7986e5ca28c524c82a26e2fabcc Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 27 May 2026 06:58:30 -0400 Subject: [PATCH 16/24] docs(screenshots): drop redundant Step 3 + condescending hardening preamble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 3 (repo onboarding + Linear project mapping) duplicated work the Prerequisites section already establishes ('Linear OAuth installed for at least one workspace'). If the user followed the Linear setup guide, both are done. If they didn't, Step 4's smoke test fails fast and the troubleshooting routes them back. Net: 30 lines of doc gone, no information lost. Renumbered Step 4 → 3 and Step 5 → 4 (and the 4a/b/c → 3a/b/c sub-steps). Also dropped the 'demo configuration optimizes for "look, it works" rather than security posture' framing on the production-hardening section. The list of followups stands on its own; the framing reads as condescending toward someone reaching the bottom of the guide. --- .../DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md | 45 +++---------------- .../using/Deploy-preview-screenshots-guide.md | 45 +++---------------- 2 files changed, 12 insertions(+), 78 deletions(-) diff --git a/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md index 9ba04895..e05ae16c 100644 --- a/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md +++ b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md @@ -99,44 +99,11 @@ Go to **your-project → Settings** in the Vercel dashboard. > **Using a different provider?** Skip Steps 1–2 and follow your provider's instructions to publish `deployment_status` events to GitHub. For Amplify Hosting, that's automatic when the app is connected via GitHub. For self-hosted CI, add a `gh api repos/.../deployments` step at the end of your deploy job. Then continue with Step 3. -### 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-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-repo \ - --label abca -``` - -The `--label` controls which Linear label triggers a task. Defaults to `bgagent`; the demo uses `abca`. Use any label you like — it just has to match what users will apply on Linear issues. - -### Step 4 — Configure the GitHub webhook +### Step 3 — Configure the GitHub webhook This wires deploys back to ABCA's screenshot pipeline. -#### 4a. Get the webhook config +#### 3a. Get the webhook config ```bash bgagent github webhook-info @@ -144,7 +111,7 @@ bgagent github webhook-info The CLI prints the webhook URL and the values to paste into GitHub. -#### 4b. Add the webhook on the GitHub repo +#### 3b. Add the webhook on the GitHub repo 1. Open `https://github.com///settings/hooks`. 2. Click **Add webhook**. @@ -157,7 +124,7 @@ The CLI prints the webhook URL and the values to paste into GitHub. - **Active**: ✓ 4. **Add webhook**. GitHub fires a `ping` event right away — under "Recent Deliveries" you should see ✅ within seconds. -#### 4c. Mirror the signing secret into AWS +#### 3c. Mirror the signing secret into AWS ```bash bgagent github set-webhook-secret @@ -165,7 +132,7 @@ bgagent github set-webhook-secret Paste the same secret you used in 4b. The CLI writes it to the stack's `GitHubWebhookSecret` Secrets Manager entry, where the receiver Lambda reads it for HMAC verification. -### Step 5 — Smoke test +### Step 4 — 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. @@ -237,7 +204,7 @@ You forgot Step 2's "Vercel Authentication: Disabled" toggle. Toggle it off, pus ## Production hardening (followups) -The demo configuration optimizes for "look, it works" rather than security posture. Before using this on a real product: +Before using this on a real product: 1. **Re-enable Vercel Standard Protection** (or your provider's equivalent) + signed bypass token; teach the screenshot processor to inject the bypass on preview URLs (followup). 2. **Scope IAM down from `bedrock-agentcore:*`** to the specific Browser action set (followup, tracked). diff --git a/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md index 27d20dfa..83daee80 100644 --- a/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md +++ b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md @@ -103,44 +103,11 @@ Go to **your-project → Settings** in the Vercel dashboard. > **Using a different provider?** Skip Steps 1–2 and follow your provider's instructions to publish `deployment_status` events to GitHub. For Amplify Hosting, that's automatic when the app is connected via GitHub. For self-hosted CI, add a `gh api repos/.../deployments` step at the end of your deploy job. Then continue with Step 3. -### 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-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-repo \ - --label abca -``` - -The `--label` controls which Linear label triggers a task. Defaults to `bgagent`; the demo uses `abca`. Use any label you like — it just has to match what users will apply on Linear issues. - -### Step 4 — Configure the GitHub webhook +### Step 3 — Configure the GitHub webhook This wires deploys back to ABCA's screenshot pipeline. -#### 4a. Get the webhook config +#### 3a. Get the webhook config ```bash bgagent github webhook-info @@ -148,7 +115,7 @@ bgagent github webhook-info The CLI prints the webhook URL and the values to paste into GitHub. -#### 4b. Add the webhook on the GitHub repo +#### 3b. Add the webhook on the GitHub repo 1. Open `https://github.com///settings/hooks`. 2. Click **Add webhook**. @@ -161,7 +128,7 @@ The CLI prints the webhook URL and the values to paste into GitHub. - **Active**: ✓ 4. **Add webhook**. GitHub fires a `ping` event right away — under "Recent Deliveries" you should see ✅ within seconds. -#### 4c. Mirror the signing secret into AWS +#### 3c. Mirror the signing secret into AWS ```bash bgagent github set-webhook-secret @@ -169,7 +136,7 @@ bgagent github set-webhook-secret Paste the same secret you used in 4b. The CLI writes it to the stack's `GitHubWebhookSecret` Secrets Manager entry, where the receiver Lambda reads it for HMAC verification. -### Step 5 — Smoke test +### Step 4 — 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. @@ -241,7 +208,7 @@ You forgot Step 2's "Vercel Authentication: Disabled" toggle. Toggle it off, pus ## Production hardening (followups) -The demo configuration optimizes for "look, it works" rather than security posture. Before using this on a real product: +Before using this on a real product: 1. **Re-enable Vercel Standard Protection** (or your provider's equivalent) + signed bypass token; teach the screenshot processor to inject the bypass on preview URLs (followup). 2. **Scope IAM down from `bedrock-agentcore:*`** to the specific Browser action set (followup, tracked). From 99e2b06ae6857c56a1ab77d7abf32ce05a121913 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 27 May 2026 07:00:26 -0400 Subject: [PATCH 17/24] =?UTF-8?q?docs(screenshots):=20drop=20'followup'=20?= =?UTF-8?q?framing=20=E2=80=94=20describe=20gaps=20as=20current=20state?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Public docs that say 'followup' read as commitments to do that work. Reframe gaps as current limitations with neutral language: - 'Production hardening (followups)' → 'Production hardening considerations'; bullets describe what to think about, not what ABCA promises to ship - Netlify table row: 'followup to support pattern matching' → '⚠ workable today only by picking one specific PR's environment string; broader pattern matching isn't shipped' - Vercel auth callout: 'tracked as a followup' → 'currently not implemented' - Non-Vercel providers table: drop 'followup #96 covers prefix routing' reference (issue numbers don't belong in user-facing docs) Net: same information, no implicit roadmap commitments. --- docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md | 17 ++++++++--------- .../using/Deploy-preview-screenshots-guide.md | 17 ++++++++--------- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md index e05ae16c..e7596992 100644 --- a/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md +++ b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md @@ -12,7 +12,7 @@ The pipeline doesn't care who built the deploy — it only listens for GitHub `d |---|---|---| | **Vercel** (managed hosting + GitHub app) | ✅ | The worked example below uses this. Default `environment` is `Preview`. | | **AWS Amplify Hosting** (Connected to GitHub) | ✅ | Posts deployment_status for each branch deploy. `environment` is the branch name — set `SCREENSHOT_TARGET_ENVIRONMENT` to your preview branch (or use the same value on every branch via the `BackgroundAgentStack` construct prop). | -| **Netlify** (managed hosting + GitHub app) | ✅ | `environment` is `Deploy Preview `. Single fixed string filter doesn't catch all PRs — followup to support pattern matching. | +| **Netlify** (managed hosting + GitHub app) | ⚠ | `environment` is `Deploy Preview `, which the current single-string `SCREENSHOT_TARGET_ENVIRONMENT` filter doesn't match across all PRs. Workable today only by picking one specific PR's environment string; broader pattern matching isn't shipped. | | **GitHub Actions** that calls `POST /repos/.../deployments` (typical for ECS/Fargate, Cloud Run, Fly.io, Railway, Cloudflare Pages, etc.) | ✅ | Your workflow controls the `environment` field; pass whatever you want and set `SCREENSHOT_TARGET_ENVIRONMENT` to match. | | **External CI** (CircleCI, GitLab, ArgoCD) that doesn't touch GitHub Deployments | ❌ | Add a final job that calls the GitHub Deployments API after the deploy succeeds — see [GitHub's example](https://docs.github.com/en/rest/deployments/deployments#create-a-deployment). | @@ -95,7 +95,7 @@ Go to **your-project → Settings** in the Vercel dashboard. #### 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. +> **Production hardening.** Real deployments should keep Vercel Authentication on **Standard Protection** and use a [signed bypass token](https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection#protection-bypass-for-automation). The screenshot processor would need to inject the bypass token as a query parameter on the preview URL it navigates to — currently not implemented. > **Using a different provider?** Skip Steps 1–2 and follow your provider's instructions to publish `deployment_status` events to GitHub. For Amplify Hosting, that's automatic when the app is connected via GitHub. For self-hosted CI, add a `gh api repos/.../deployments` step at the end of your deploy job. Then continue with Step 3. @@ -153,7 +153,7 @@ The pipeline filters incoming webhooks against `SCREENSHOT_TARGET_ENVIRONMENT` ( |---|---|---| | Vercel | `Preview` | leave default | | Amplify Hosting | branch name (e.g. `main`, `staging`) | the branch you treat as preview | -| Netlify | `Deploy Preview ` | currently not directly matchable — followup #96 covers prefix routing | +| Netlify | `Deploy Preview ` | currently not directly matchable across all PRs (single fixed-string filter only) | | GitHub Actions custom | whatever your workflow passes | match it exactly | ## Troubleshooting @@ -202,11 +202,10 @@ If it 403s, check that the bucket policy includes the OAC service principal (CDK 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) +## Production hardening considerations -Before using this on a real product: +Things to think about before using this on a real product: -1. **Re-enable Vercel Standard Protection** (or your provider's equivalent) + signed bypass token; teach the screenshot processor to inject the 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`). +- **Deploy protection.** This guide turns Vercel Authentication off so the headless browser can render the preview. For real use, you'll want it back on with a signed bypass token (or your provider's equivalent) and the bypass injected onto the preview URL the screenshot processor navigates to. +- **IAM scope.** The screenshot processor's IAM grants `bedrock-agentcore:*`; tightening to the specific Browser action set is preferable. +- **Sensitive content.** If your previews include PII or other regulated content, consider CloudFront access logs + a WAF in front of the public CDN, and shorten screenshot retention below the 30-day default (constant in `cdk/src/constructs/screenshot-bucket.ts`). diff --git a/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md index 83daee80..e20ec811 100644 --- a/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md +++ b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md @@ -16,7 +16,7 @@ The pipeline doesn't care who built the deploy — it only listens for GitHub `d |---|---|---| | **Vercel** (managed hosting + GitHub app) | ✅ | The worked example below uses this. Default `environment` is `Preview`. | | **AWS Amplify Hosting** (Connected to GitHub) | ✅ | Posts deployment_status for each branch deploy. `environment` is the branch name — set `SCREENSHOT_TARGET_ENVIRONMENT` to your preview branch (or use the same value on every branch via the `BackgroundAgentStack` construct prop). | -| **Netlify** (managed hosting + GitHub app) | ✅ | `environment` is `Deploy Preview `. Single fixed string filter doesn't catch all PRs — followup to support pattern matching. | +| **Netlify** (managed hosting + GitHub app) | ⚠ | `environment` is `Deploy Preview `, which the current single-string `SCREENSHOT_TARGET_ENVIRONMENT` filter doesn't match across all PRs. Workable today only by picking one specific PR's environment string; broader pattern matching isn't shipped. | | **GitHub Actions** that calls `POST /repos/.../deployments` (typical for ECS/Fargate, Cloud Run, Fly.io, Railway, Cloudflare Pages, etc.) | ✅ | Your workflow controls the `environment` field; pass whatever you want and set `SCREENSHOT_TARGET_ENVIRONMENT` to match. | | **External CI** (CircleCI, GitLab, ArgoCD) that doesn't touch GitHub Deployments | ❌ | Add a final job that calls the GitHub Deployments API after the deploy succeeds — see [GitHub's example](https://docs.github.com/en/rest/deployments/deployments#create-a-deployment). | @@ -99,7 +99,7 @@ Go to **your-project → Settings** in the Vercel dashboard. #### 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. +> **Production hardening.** Real deployments should keep Vercel Authentication on **Standard Protection** and use a [signed bypass token](https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection#protection-bypass-for-automation). The screenshot processor would need to inject the bypass token as a query parameter on the preview URL it navigates to — currently not implemented. > **Using a different provider?** Skip Steps 1–2 and follow your provider's instructions to publish `deployment_status` events to GitHub. For Amplify Hosting, that's automatic when the app is connected via GitHub. For self-hosted CI, add a `gh api repos/.../deployments` step at the end of your deploy job. Then continue with Step 3. @@ -157,7 +157,7 @@ The pipeline filters incoming webhooks against `SCREENSHOT_TARGET_ENVIRONMENT` ( |---|---|---| | Vercel | `Preview` | leave default | | Amplify Hosting | branch name (e.g. `main`, `staging`) | the branch you treat as preview | -| Netlify | `Deploy Preview ` | currently not directly matchable — followup #96 covers prefix routing | +| Netlify | `Deploy Preview ` | currently not directly matchable across all PRs (single fixed-string filter only) | | GitHub Actions custom | whatever your workflow passes | match it exactly | ## Troubleshooting @@ -206,11 +206,10 @@ If it 403s, check that the bucket policy includes the OAC service principal (CDK 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) +## Production hardening considerations -Before using this on a real product: +Things to think about before using this on a real product: -1. **Re-enable Vercel Standard Protection** (or your provider's equivalent) + signed bypass token; teach the screenshot processor to inject the 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`). +- **Deploy protection.** This guide turns Vercel Authentication off so the headless browser can render the preview. For real use, you'll want it back on with a signed bypass token (or your provider's equivalent) and the bypass injected onto the preview URL the screenshot processor navigates to. +- **IAM scope.** The screenshot processor's IAM grants `bedrock-agentcore:*`; tightening to the specific Browser action set is preferable. +- **Sensitive content.** If your previews include PII or other regulated content, consider CloudFront access logs + a WAF in front of the public CDN, and shorten screenshot retention below the 30-day default (constant in `cdk/src/constructs/screenshot-bucket.ts`). From a444266848107051b7fdd59c2621ca4480d1591f Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 27 May 2026 07:03:31 -0400 Subject: [PATCH 18/24] =?UTF-8?q?docs(screenshots):=20de-Linear-ize=20?= =?UTF-8?q?=E2=80=94=20Linear=20is=20opt-in,=20not=20required?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screenshot pipeline only needs GitHub. Linear-side posting was phrased as a hard requirement throughout the guide because the demo flow happens to use Linear, but a non-Linear team gets a perfectly useful integration: screenshots land on GitHub PRs, the Linear lookup silently no-ops. Reframings: - Lead-in: 'on both the open GitHub PR AND the linked Linear issue' → 'on the open GitHub PR. If you also have Linear configured, the same screenshot is posted to the linked Linear issue as a bonus.' Plus a note on the gating (LinearWorkspaceRegistryTable having active rows is what flips the Linear path on). - 'How it works': step 4 (Linear post) marked optional with the silent-skip behaviour spelled out - Architecture comment: 'GitHub PR comment + Linear issue comment' → '... (+ Linear issue comment if linked)' - Prerequisites: Linear OAuth marked optional with rationale - Smoke test: rewritten as PR-driven by default ('open any PR on the configured repo'), with Linear-driven path as a follow-on paragraph ('If you also have Linear configured...') - Troubleshooting: 'Linear is best-effort' → 'opt-in and best- effort', explicit note that skipping is normal without Linear --- .../DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md | 27 +++++++------------ .../using/Deploy-preview-screenshots-guide.md | 27 +++++++------------ 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md index e7596992..c610f85c 100644 --- a/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md +++ b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md @@ -1,8 +1,8 @@ # Deploy preview screenshots setup guide -Wire your 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. +Wire your repo into ABCA so that every preview deploy gets screenshotted and posted as a comment on the open GitHub PR. If you also have Linear configured, the same screenshot is posted to the linked Linear issue as a bonus. -> **Prerequisite:** 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. +> The pipeline only needs GitHub. Linear posting is opt-in: present iff `LinearWorkspaceRegistryTable` has at least one active row (configured via [Linear setup guide](./LINEAR_SETUP_GUIDE.md)). Without Linear, the GitHub-side screenshot still works; the Linear-side just no-ops silently. ## Works with any provider that posts `deployment_status` @@ -25,12 +25,12 @@ If your provider gives you that, you're done. The example below is Vercel becaus ## What you get -When ABCA opens a PR for a Linear-driven task, your provider deploys the preview, posts a `deployment_status` event back to GitHub, and ABCA's webhook receiver: +When you (or the agent) push to a branch that triggers a preview deploy, your provider 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 +4. **(Optional)** If Linear is wired: 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. Skipped silently if Linear isn't configured or no identifier is present. End-to-end latency: typically 10–15 seconds after your provider reports the deploy. @@ -53,20 +53,20 @@ agent push → provider preview build → deployment_status webhook ↓ CloudFront-served public URL ↓ - GitHub PR comment + Linear issue comment + GitHub PR comment (+ Linear issue comment if linked) ``` 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. +- **Private S3 + CloudFront with OAC.** Screenshot bucket is fully private; CloudFront serves images anonymously over HTTPS so GitHub markdown image embeds (and Linear's, when configured) can render them without auth. - **WAF exemption.** The `/v1/github/webhook` path is excluded from the AWSManagedRulesCommonRuleSet because deployment_status payloads (which embed absolute deploy URLs) trip `GenericRFI_BODY` otherwise. ## Prerequisites - ABCA stack deployed (`mise //cdk:deploy`) — confirm `GitHubWebhookUrl` + `GitHubWebhookSecretArn` + `ScreenshotCloudFrontDomain` are listed in the stack outputs -- Linear OAuth installed for at least one workspace (`bgagent linear setup `) +- (Optional) Linear OAuth installed for at least one workspace (`bgagent linear setup `) — only required if you want screenshots posted to Linear issues in addition to the GitHub PR - A GitHub repo you own - Your deploy provider connected to that repo (the example uses Vercel) - AWS CLI logged in to the same account as the ABCA stack @@ -134,16 +134,9 @@ Paste the same secret you used in 4b. The CLI writes it to the stack's `GitHubWe ### Step 4 — 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 - - Provider builds the preview - - **Screenshot lands on the GitHub PR** as a comment - - **Same screenshot lands on the Linear issue** as a comment +Open any PR on the configured repo (push a commit, open a PR however you normally do — GitHub UI, `gh pr create`, GitHub Actions, agent, etc.) Wait 2–5 minutes for your provider to build the preview. The screenshot should land on the PR as a markdown image comment. -If the GitHub comment shows up but Linear doesn't (or vice versa), see Troubleshooting below. +**If you also have Linear configured:** create a Linear issue in a mapped project (e.g. "Update homepage heading"), apply the trigger label, and watch the agent open a PR. The same screenshot lands on both the GitHub PR and the Linear issue. If the GitHub comment shows but Linear doesn't, see Troubleshooting. ## Configuring for non-Vercel providers @@ -183,7 +176,7 @@ Then tail the function's CloudWatch log group. Common silent skips: ### 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 GitHub-side post is the primary path; Linear is opt-in and best-effort. Skipping the Linear post is normal if you don't have Linear configured. If you do, 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. diff --git a/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md index e20ec811..5d19517a 100644 --- a/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md +++ b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md @@ -4,9 +4,9 @@ title: Deploy preview screenshots guide # Deploy preview screenshots setup guide -Wire your 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. +Wire your repo into ABCA so that every preview deploy gets screenshotted and posted as a comment on the open GitHub PR. If you also have Linear configured, the same screenshot is posted to the linked Linear issue as a bonus. -> **Prerequisite:** 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. +> The pipeline only needs GitHub. Linear posting is opt-in: present iff `LinearWorkspaceRegistryTable` has at least one active row (configured via [Linear setup guide](/using/linear-setup-guide)). Without Linear, the GitHub-side screenshot still works; the Linear-side just no-ops silently. ## Works with any provider that posts `deployment_status` @@ -29,12 +29,12 @@ If your provider gives you that, you're done. The example below is Vercel becaus ## What you get -When ABCA opens a PR for a Linear-driven task, your provider deploys the preview, posts a `deployment_status` event back to GitHub, and ABCA's webhook receiver: +When you (or the agent) push to a branch that triggers a preview deploy, your provider 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 +4. **(Optional)** If Linear is wired: 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. Skipped silently if Linear isn't configured or no identifier is present. End-to-end latency: typically 10–15 seconds after your provider reports the deploy. @@ -57,20 +57,20 @@ agent push → provider preview build → deployment_status webhook ↓ CloudFront-served public URL ↓ - GitHub PR comment + Linear issue comment + GitHub PR comment (+ Linear issue comment if linked) ``` 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. +- **Private S3 + CloudFront with OAC.** Screenshot bucket is fully private; CloudFront serves images anonymously over HTTPS so GitHub markdown image embeds (and Linear's, when configured) can render them without auth. - **WAF exemption.** The `/v1/github/webhook` path is excluded from the AWSManagedRulesCommonRuleSet because deployment_status payloads (which embed absolute deploy URLs) trip `GenericRFI_BODY` otherwise. ## Prerequisites - ABCA stack deployed (`mise //cdk:deploy`) — confirm `GitHubWebhookUrl` + `GitHubWebhookSecretArn` + `ScreenshotCloudFrontDomain` are listed in the stack outputs -- Linear OAuth installed for at least one workspace (`bgagent linear setup `) +- (Optional) Linear OAuth installed for at least one workspace (`bgagent linear setup `) — only required if you want screenshots posted to Linear issues in addition to the GitHub PR - A GitHub repo you own - Your deploy provider connected to that repo (the example uses Vercel) - AWS CLI logged in to the same account as the ABCA stack @@ -138,16 +138,9 @@ Paste the same secret you used in 4b. The CLI writes it to the stack's `GitHubWe ### Step 4 — 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 - - Provider builds the preview - - **Screenshot lands on the GitHub PR** as a comment - - **Same screenshot lands on the Linear issue** as a comment +Open any PR on the configured repo (push a commit, open a PR however you normally do — GitHub UI, `gh pr create`, GitHub Actions, agent, etc.) Wait 2–5 minutes for your provider to build the preview. The screenshot should land on the PR as a markdown image comment. -If the GitHub comment shows up but Linear doesn't (or vice versa), see Troubleshooting below. +**If you also have Linear configured:** create a Linear issue in a mapped project (e.g. "Update homepage heading"), apply the trigger label, and watch the agent open a PR. The same screenshot lands on both the GitHub PR and the Linear issue. If the GitHub comment shows but Linear doesn't, see Troubleshooting. ## Configuring for non-Vercel providers @@ -187,7 +180,7 @@ Then tail the function's CloudWatch log group. Common silent skips: ### 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 GitHub-side post is the primary path; Linear is opt-in and best-effort. Skipping the Linear post is normal if you don't have Linear configured. If you do, 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. From 6e57515c3febe8a72f777bac94afab7c54669485 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Thu, 28 May 2026 17:44:33 -0400 Subject: [PATCH 19/24] feat(screenshot): hide URL behind 'preview link' label in comments GitHub PR comment now reads 'From [preview link](url)' and Linear comment reads '[Preview link](url)' instead of pasting the bare URL. Cleaner visual when the same comment is posted on both surfaces. --- cdk/src/handlers/github-webhook-processor.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index 23a3702e..8c7ce378 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -355,7 +355,7 @@ function renderCommentBody(publicUrl: string, previewUrl: string): string { '', `[![preview](${publicUrl})](${previewUrl})`, '', - `_From [${previewUrl}](${previewUrl}) — captured automatically by ABCA after the deploy finished._`, + `_From [preview link](${previewUrl}) — captured automatically by ABCA after the deploy finished._`, ].join('\n'); } @@ -371,7 +371,7 @@ function renderLinearCommentBody(publicUrl: string, previewUrl: string): string '', `![preview](${publicUrl})`, '', - `Live preview: [${previewUrl}](${previewUrl})`, + `[Preview link](${previewUrl})`, '', '_Captured automatically by ABCA after the deploy finished._', ].join('\n'); From 7d994b8b0acee442ca269599fa3479cc86e6aaf7 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Mon, 1 Jun 2026 16:42:37 -0700 Subject: [PATCH 20/24] docs(screenshots): add USER_GUIDE / COST_MODEL / ROADMAP coverage Closes the doc gaps from the screenshot feature followup list: - USER_GUIDE.md: new 'Preview-deploy screenshots (optional)' subsection under Notifications, points at DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md. - COST_MODEL.md: 'Optional: deploy-preview screenshots' table covering AgentCore Browser session, Lambda processor, S3, CloudFront line items (~$0.01 per screenshot, dominated by Browser session time). - ROADMAP.md: marks the feature shipped under Notification plane with a one-line description of the trigger model and post-deploy latency. Mirrors regenerated via docs/scripts/sync-starlight.mjs. --- docs/design/COST_MODEL.md | 16 ++++++++++++++++ docs/guides/ROADMAP.md | 1 + docs/guides/USER_GUIDE.md | 8 ++++++++ docs/src/content/docs/architecture/Cost-model.md | 16 ++++++++++++++++ docs/src/content/docs/roadmap/Roadmap.md | 1 + docs/src/content/docs/using/Task-lifecycle.md | 10 +++++++++- 6 files changed, 51 insertions(+), 1 deletion(-) diff --git a/docs/design/COST_MODEL.md b/docs/design/COST_MODEL.md index 1136e2ff..cea17d4e 100644 --- a/docs/design/COST_MODEL.md +++ b/docs/design/COST_MODEL.md @@ -47,6 +47,22 @@ Assuming a typical task: 1–2 hours, Claude Sonnet, ~100K input tokens, ~20K ou | Custom step Lambdas | $0–0.05 | Only if configured. Per-invocation: ~$0.01 per step. | | **Total per task** | **$2–15** | Bedrock tokens dominate (>90% of per-task cost). New interactive features add <$0.01 per task. | +### Optional: deploy-preview screenshots + +The screenshot pipeline (see [Deploy preview screenshots guide](../guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md)) is opt-in per repo and deterministic — no LLM, no agent runtime. Only fires when a connected deploy provider posts `deployment_status: success`. + +| Component | Estimated cost per screenshot | Notes | +|---|---|---| +| AgentCore Browser session | $0.005–0.015 | ~30–60 s of `aws.browser.v1` for navigate + capture. Per-second billing. | +| Lambda processor | <$0.001 | 512 MB, ~10–20 s wall time per invocation. | +| S3 PutObject + storage | <$0.001 | One PNG (~200 KB–2 MB), 30-day TTL via lifecycle. | +| CloudFront request + bytes-out | <$0.001 | First-render fetch from GitHub markdown image proxy + a small number of viewer fetches. | +| **Total per screenshot** | **~$0.01** | Dominated by AgentCore Browser session time. | + +Baseline overhead (CloudFront distribution + S3 bucket idle) is <$1/month and absorbed into the existing infrastructure baseline above. CloudFront has no per-distribution monthly fee; you pay only per-request and per-byte-out. + +A high-volume team with ~500 preview deploys per month would add ~$5/month to the per-task variable line, which is rounding error compared to Bedrock token costs. + ### Cost sensitivity analysis | Factor | Impact on cost | Mitigation | diff --git a/docs/guides/ROADMAP.md b/docs/guides/ROADMAP.md index cf53fd6b..7e190b7e 100644 --- a/docs/guides/ROADMAP.md +++ b/docs/guides/ROADMAP.md @@ -76,6 +76,7 @@ What's shipped and what's coming next. - [x] **GitHub edit-in-place** - Single status comment per task on the target PR, edited in place as progress events fire (phase, milestone, cost, link) - [x] **Routable agent milestones** - Named checkpoints (`pr_created`, `nudge_acknowledged`) unwrapped against allowlist for channel filter matching - [x] **Slack notification dispatcher** - FanOut Block Kit messages for Slack-origin tasks (lifecycle events, threaded replies, terminal dedup, in-thread cancel). Generic fallback text for unmapped event types (e.g. some milestones); richer milestone and approval-gate rendering is follow-up work +- [x] **Deploy-preview screenshots** - Listens for GitHub `deployment_status: success` events from any provider (Vercel, Amplify Hosting, Netlify, GitHub Actions); captures the preview URL via AgentCore Browser; posts a markdown image comment on the open PR (and on the linked Linear issue if Linear is configured). Lambda-only, deterministic, ~10–15 s post-deploy. See [Deploy preview screenshots guide](./DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md). - [ ] **Email dispatcher** - Log-only stub; pending SES integration ### Channels diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 417a2053..af9eeb96 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -1014,6 +1014,14 @@ The notification plane uses DynamoDB Streams to fan out task events to channel-s The status comment shows: current phase, last milestone, cost so far, and a link to the task. It updates on key events (`session_started`, `pr_created`, `task_completed`, `task_failed`, `nudge_acknowledged`, and routable agent milestones). +### Preview-deploy screenshots (optional) + +If your repo is wired to a deploy provider that publishes GitHub `deployment_status` events (Vercel, Amplify Hosting, Netlify, GitHub Actions custom CD, etc.), ABCA can capture a full-page screenshot of each preview URL and post it as an image comment on the open PR — and on the linked Linear issue if Linear is configured. + +This runs independently of the agent: there's no LLM involved, just a Lambda that drives a headless browser via AgentCore Browser. End-to-end latency is typically 10–15 seconds after the deploy provider reports success. + +Setup is opt-in and per-repo. See the [Deploy preview screenshots guide](./DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md) for the wiring (one webhook on the repo, one secret pasted into AWS). + ## What the agent does The agent is the part of the platform that actually writes code. When the orchestrator finishes preparing a task (admission, context hydration, pre-flight checks), it hands off to an agent running inside an isolated compute environment. Today the platform supports **Amazon Bedrock AgentCore Runtime** as the default compute backend - each agent session runs in a Firecracker MicroVM with session-scoped storage and automatic cleanup. The architecture is designed to support additional compute backends (ECS on Fargate, ECS on EC2) for repositories that need more resources or custom toolchains beyond the AgentCore 2 GB image limit. See the [Compute design](/sample-autonomous-cloud-coding-agents/architecture/compute) for the full comparison. diff --git a/docs/src/content/docs/architecture/Cost-model.md b/docs/src/content/docs/architecture/Cost-model.md index 434eca62..d77c959f 100644 --- a/docs/src/content/docs/architecture/Cost-model.md +++ b/docs/src/content/docs/architecture/Cost-model.md @@ -51,6 +51,22 @@ Assuming a typical task: 1–2 hours, Claude Sonnet, ~100K input tokens, ~20K ou | Custom step Lambdas | $0–0.05 | Only if configured. Per-invocation: ~$0.01 per step. | | **Total per task** | **$2–15** | Bedrock tokens dominate (>90% of per-task cost). New interactive features add <$0.01 per task. | +### Optional: deploy-preview screenshots + +The screenshot pipeline (see [Deploy preview screenshots guide](/using/deploy-preview-screenshots-guide)) is opt-in per repo and deterministic — no LLM, no agent runtime. Only fires when a connected deploy provider posts `deployment_status: success`. + +| Component | Estimated cost per screenshot | Notes | +|---|---|---| +| AgentCore Browser session | $0.005–0.015 | ~30–60 s of `aws.browser.v1` for navigate + capture. Per-second billing. | +| Lambda processor | <$0.001 | 512 MB, ~10–20 s wall time per invocation. | +| S3 PutObject + storage | <$0.001 | One PNG (~200 KB–2 MB), 30-day TTL via lifecycle. | +| CloudFront request + bytes-out | <$0.001 | First-render fetch from GitHub markdown image proxy + a small number of viewer fetches. | +| **Total per screenshot** | **~$0.01** | Dominated by AgentCore Browser session time. | + +Baseline overhead (CloudFront distribution + S3 bucket idle) is <$1/month and absorbed into the existing infrastructure baseline above. CloudFront has no per-distribution monthly fee; you pay only per-request and per-byte-out. + +A high-volume team with ~500 preview deploys per month would add ~$5/month to the per-task variable line, which is rounding error compared to Bedrock token costs. + ### Cost sensitivity analysis | Factor | Impact on cost | Mitigation | diff --git a/docs/src/content/docs/roadmap/Roadmap.md b/docs/src/content/docs/roadmap/Roadmap.md index 2b6e4d2b..484e52d3 100644 --- a/docs/src/content/docs/roadmap/Roadmap.md +++ b/docs/src/content/docs/roadmap/Roadmap.md @@ -80,6 +80,7 @@ What's shipped and what's coming next. - [x] **GitHub edit-in-place** - Single status comment per task on the target PR, edited in place as progress events fire (phase, milestone, cost, link) - [x] **Routable agent milestones** - Named checkpoints (`pr_created`, `nudge_acknowledged`) unwrapped against allowlist for channel filter matching - [x] **Slack notification dispatcher** - FanOut Block Kit messages for Slack-origin tasks (lifecycle events, threaded replies, terminal dedup, in-thread cancel). Generic fallback text for unmapped event types (e.g. some milestones); richer milestone and approval-gate rendering is follow-up work +- [x] **Deploy-preview screenshots** - Listens for GitHub `deployment_status: success` events from any provider (Vercel, Amplify Hosting, Netlify, GitHub Actions); captures the preview URL via AgentCore Browser; posts a markdown image comment on the open PR (and on the linked Linear issue if Linear is configured). Lambda-only, deterministic, ~10–15 s post-deploy. See [Deploy preview screenshots guide](/using/deploy-preview-screenshots-guide). - [ ] **Email dispatcher** - Log-only stub; pending SES integration ### Channels diff --git a/docs/src/content/docs/using/Task-lifecycle.md b/docs/src/content/docs/using/Task-lifecycle.md index da61a951..514d48d2 100644 --- a/docs/src/content/docs/using/Task-lifecycle.md +++ b/docs/src/content/docs/using/Task-lifecycle.md @@ -101,4 +101,12 @@ When a task targets a pull request (`pr_iteration` or `pr_review`), the platform The notification plane uses DynamoDB Streams to fan out task events to channel-specific dispatchers. Currently the GitHub edit-in-place dispatcher is active; Slack and Email dispatchers are planned. -The status comment shows: current phase, last milestone, cost so far, and a link to the task. It updates on key events (`session_started`, `pr_created`, `task_completed`, `task_failed`, `nudge_acknowledged`, and routable agent milestones). \ No newline at end of file +The status comment shows: current phase, last milestone, cost so far, and a link to the task. It updates on key events (`session_started`, `pr_created`, `task_completed`, `task_failed`, `nudge_acknowledged`, and routable agent milestones). + +### Preview-deploy screenshots (optional) + +If your repo is wired to a deploy provider that publishes GitHub `deployment_status` events (Vercel, Amplify Hosting, Netlify, GitHub Actions custom CD, etc.), ABCA can capture a full-page screenshot of each preview URL and post it as an image comment on the open PR — and on the linked Linear issue if Linear is configured. + +This runs independently of the agent: there's no LLM involved, just a Lambda that drives a headless browser via AgentCore Browser. End-to-end latency is typically 10–15 seconds after the deploy provider reports success. + +Setup is opt-in and per-repo. See the [Deploy preview screenshots guide](/using/deploy-preview-screenshots-guide) for the wiring (one webhook on the repo, one secret pasted into AWS). \ No newline at end of file From f9824f4de90a630697781f88ff04e77e59c3ca9f Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 2 Jun 2026 09:27:23 -0700 Subject: [PATCH 21/24] docs(linear): clarify teammate-onboarding handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'Inviting teammates' section was missing the prerequisite that the teammate needs their own ABCA account (Cognito user + configured CLI) before they can redeem a Linear invite code. New flow walks through: Admin: invite-user (Cognito) → invite-user (Linear) Teammate: configure --from-bundle → login → linear link with cross-references to USER_GUIDE.md's 'Joining an existing deployment' for the Cognito-side details. Also corrects the stale 'auto-links the person running the wizard' claim — setup now offers an inline picker (opt-in by admin), not an automatic mapping. --- docs/guides/LINEAR_SETUP_GUIDE.md | 28 +++++++++++++++---- .../content/docs/using/Linear-setup-guide.md | 28 +++++++++++++++---- 2 files changed, 44 insertions(+), 12 deletions(-) diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index d08aedf5..423bd63a 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -96,19 +96,35 @@ Apply the trigger label to a Linear issue in the onboarded project. The agent sh ## Inviting teammates -The setup walkthrough auto-links **the person running the wizard**. To onboard additional teammates so they can trigger tasks from Linear: +The setup walkthrough offers an inline self-link picker that lets the **person running the wizard** map their own Linear identity to their Cognito sub. To onboard additional teammates so they can trigger tasks from Linear from their own ABCA accounts, run: + +### Admin: generate the invite ```bash bgagent linear invite-user ``` -The admin picks the teammate from a Linear member picker. The CLI generates a one-time code (24h TTL) and prints a command to send to the teammate (Slack/email/etc). The teammate runs: +The CLI shows a picker of human Linear members in the workspace. After you pick the teammate, it generates a one-time code (24h TTL) and prints a CLI command to send them via Slack/email/etc. -```bash -bgagent linear link -``` +### Teammate: redeem the invite + +The teammate needs their own ABCA account first (Cognito user + configured CLI). If they don't have one yet: + +1. **Admin** runs `bgagent admin invite-user teammate@example.com` to create their Cognito user (see [User guide → Joining an existing deployment](./USER_GUIDE.md#joining-an-existing-deployment) for the full Cognito-side flow). +2. **Teammate** pastes the bundle + password from the admin into: + + ```bash + bgagent configure --from-bundle + bgagent login --username teammate@example.com + ``` + +3. **Teammate** redeems the Linear invite code: + + ```bash + bgagent linear link + ``` -The CLI shows them the Linear identity name+email and asks for confirmation **before** writing the mapping row. If the admin picked the wrong member, the teammate sees the mismatch and aborts. + The CLI shows them the Linear identity name+email and asks for confirmation **before** writing the mapping row. If the admin picked the wrong member, the teammate sees the mismatch and aborts. After confirmation, the binding is recorded — the teammate can now apply the trigger label to a Linear issue and it'll fire as a task under their ABCA account (their concurrency, their cost attribution, their notifications). ### Why this two-step handshake diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index 2a11d782..aa39d07c 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -100,19 +100,35 @@ Apply the trigger label to a Linear issue in the onboarded project. The agent sh ## Inviting teammates -The setup walkthrough auto-links **the person running the wizard**. To onboard additional teammates so they can trigger tasks from Linear: +The setup walkthrough offers an inline self-link picker that lets the **person running the wizard** map their own Linear identity to their Cognito sub. To onboard additional teammates so they can trigger tasks from Linear from their own ABCA accounts, run: + +### Admin: generate the invite ```bash bgagent linear invite-user ``` -The admin picks the teammate from a Linear member picker. The CLI generates a one-time code (24h TTL) and prints a command to send to the teammate (Slack/email/etc). The teammate runs: +The CLI shows a picker of human Linear members in the workspace. After you pick the teammate, it generates a one-time code (24h TTL) and prints a CLI command to send them via Slack/email/etc. -```bash -bgagent linear link -``` +### Teammate: redeem the invite + +The teammate needs their own ABCA account first (Cognito user + configured CLI). If they don't have one yet: + +1. **Admin** runs `bgagent admin invite-user teammate@example.com` to create their Cognito user (see [User guide → Joining an existing deployment](/using/overview#joining-an-existing-deployment) for the full Cognito-side flow). +2. **Teammate** pastes the bundle + password from the admin into: + + ```bash + bgagent configure --from-bundle + bgagent login --username teammate@example.com + ``` + +3. **Teammate** redeems the Linear invite code: + + ```bash + bgagent linear link + ``` -The CLI shows them the Linear identity name+email and asks for confirmation **before** writing the mapping row. If the admin picked the wrong member, the teammate sees the mismatch and aborts. + The CLI shows them the Linear identity name+email and asks for confirmation **before** writing the mapping row. If the admin picked the wrong member, the teammate sees the mismatch and aborts. After confirmation, the binding is recorded — the teammate can now apply the trigger label to a Linear issue and it'll fire as a task under their ABCA account (their concurrency, their cost attribution, their notifications). ### Why this two-step handshake From d4c3aa0875ffc13cd712ee810c07954b0bd6f713 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 2 Jun 2026 13:56:23 -0700 Subject: [PATCH 22/24] fix(github-cli): de-Vercel-ize webhook-info / set-webhook-secret strings Last batch of stale 'Vercel' framing in CLI command output, missed in the original de-Vercel-ize sweep. Provider-agnostic now: webhook-info header reads 'preview-deploy screenshot pipeline', the closing note lists Vercel/Amplify/Netlify/GitHub Actions as example providers, and the smoke-test instruction says 'push to a PR-attached branch' rather than 'trigger a Vercel preview deploy'. No behaviour change; pure copy. --- cli/src/commands/github.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/cli/src/commands/github.ts b/cli/src/commands/github.ts index cd119a96..1b090990 100644 --- a/cli/src/commands/github.ts +++ b/cli/src/commands/github.ts @@ -29,7 +29,7 @@ import { CliError } from '../errors'; export function makeGithubCommand(): Command { const github = new Command('github') - .description('Manage GitHub integration (deployment-status webhook for Vercel preview screenshots)'); + .description('Manage GitHub integration (deployment-status webhook for preview-deploy screenshots)'); github.addCommand( new Command('webhook-info') @@ -59,7 +59,7 @@ export function makeGithubCommand(): Command { const bar = '═'.repeat(72); console.log(bar); - console.log('GitHub webhook configuration (Vercel preview screenshot pipeline)'); + console.log('GitHub webhook configuration (preview-deploy screenshot pipeline)'); console.log(bar); console.log(); console.log('In GitHub, on the repo whose previews should generate screenshots:'); @@ -81,8 +81,9 @@ export function makeGithubCommand(): Command { console.log(' (Stack output GitHubWebhookSecretArn not found — check `aws cloudformation describe-stacks`.)'); } console.log(); - console.log('Note: Vercel posts deployment_status events via the GitHub Deployments API,'); - console.log('so this single webhook covers all Vercel-connected previews on the repo.'); + console.log('Note: deploy providers (Vercel, Amplify Hosting, Netlify, GitHub Actions'); + console.log('custom CD, etc.) post deployment_status events via the GitHub Deployments'); + console.log('API, so this single webhook covers every preview your provider builds.'); console.log(bar); }), ); @@ -150,9 +151,9 @@ export function makeGithubCommand(): Command { console.log(); console.log('✅ Stored webhook signing secret.'); console.log(); - console.log('Test by triggering a Vercel preview deploy on the configured repo. The'); - console.log('receiver Lambda log group should show a successful HMAC verification on'); - console.log('the next deployment_status event.'); + console.log('Test by triggering a preview deploy on the configured repo (push to a'); + console.log('PR-attached branch). The receiver Lambda log group should show a successful'); + console.log('HMAC verification on the next deployment_status event.'); }), ); From dac4e31679091f40c52e7219baf896ba891b76d7 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 2 Jun 2026 14:14:37 -0700 Subject: [PATCH 23/24] fix(github-cli): replace template literal with single quotes (eslint mutation) The local build's eslint --fix step rewrote a no-interpolation template literal to single quotes; CI's 'Fail build on mutation' guard caught that the mutation wasn't committed. Apply the fix. --- cli/src/commands/github.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/commands/github.ts b/cli/src/commands/github.ts index 1b090990..1c25caf6 100644 --- a/cli/src/commands/github.ts +++ b/cli/src/commands/github.ts @@ -74,7 +74,7 @@ export function makeGithubCommand(): Command { console.log('receiver can verify the HMAC:'); console.log(); if (webhookSecretArn) { - console.log(` bgagent github set-webhook-secret # interactive prompt`); + console.log(' bgagent github set-webhook-secret # interactive prompt'); console.log(); console.log(` Secret ARN: ${webhookSecretArn}`); } else { From e791e621e834b3b612a68fb619b55befcf5d4fcf Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 3 Jun 2026 18:37:33 -0700 Subject: [PATCH 24/24] =?UTF-8?q?fix(screenshot):=20krokoko=20PR-241=20rev?= =?UTF-8?q?iew=20=E2=80=94=20scope=20IAM=20+=20cosmetic=20Vercel=20mention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #94 (the existing 'scope IAM down from bedrock-agentcore:*' followup task). Addresses krokoko's PR #241 review: 1. (BLOCKING per review #1) IAM action wildcard — narrow bedrock-agentcore:* to the three calls the screenshot processor actually makes: - StartBrowserSession (control plane, public CLI command) - StopBrowserSession (control plane, public CLI command) - ConnectBrowserAutomationStream (SigV4-presigned WSS dial; not in the public CLI list but verified live against the deployed dev stack — IAM accepts the action name) Resource wildcard remains because AgentCore Browser sessions are ephemeral with no stable ARN; the IAM5 suppression on the construct already documents that. Previous behaviour granted every AgentCore action surface (memory, runtime, gateway, identity, code-interpreter) the screenshot path doesn't use. Tightening to the call set leaves a precise audit surface; if a future API change needs another action, IAM denies with the action name in CloudTrail and we add it explicitly. 2. (NIT per review #7) Stale 'Vercel' wording on ScreenshotBucketName CfnOutput description, plus an adjacent comment in agent.ts that said 'Vercel-style preview deploys'. Both replaced with provider-agnostic phrasing — the pipeline listens for any provider that posts deployment_status (Vercel, Amplify, Netlify, GitHub Actions custom CD). No behavioural change in either fix. --- .../github-screenshot-integration.ts | 28 +++++++++++++++---- cdk/src/stacks/agent.ts | 13 +++++---- 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts index 7fd66cd3..6c8fe55d 100644 --- a/cdk/src/constructs/github-screenshot-integration.ts +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -186,13 +186,29 @@ export class GitHubScreenshotIntegration extends Construct { // 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. + // are ephemeral), so the resource wildcard is required — annotated + // with a cdk-nag suppression below. + // + // Actions are scoped to the three calls the handler actually makes: + // - StartBrowserSession + StopBrowserSession (REST control plane, + // in the public CLI command list) + // - ConnectBrowserAutomationStream (the SigV4-presigned WSS dial; + // not in the public CLI command list, but verified live against + // the deployed dev stack — IAM accepts the action name even + // though aws cli help doesn't surface it) + // + // Previously this used `bedrock-agentcore:*` which granted the + // entire AgentCore action surface (memory, runtime, gateway, + // identity, code-interpreter). Per krokoko's PR #241 review item + // #1: scope down to least privilege. If a future API change adds a + // call we need, IAM will deny with the specific action name in + // CloudTrail and we can add it explicitly. this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ - actions: ['bedrock-agentcore:*'], + actions: [ + 'bedrock-agentcore:StartBrowserSession', + 'bedrock-agentcore:StopBrowserSession', + 'bedrock-agentcore:ConnectBrowserAutomationStream', + ], resources: ['*'], })); diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 1e96fc68..63105841 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -836,11 +836,12 @@ export class AgentStack extends Stack { }); // --- 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. + // Listens for GitHub deployment_status events from any provider + // (Vercel, Amplify Hosting, Netlify, GitHub Actions custom CD), + // 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, @@ -864,7 +865,7 @@ export class AgentStack extends Stack { new CfnOutput(this, 'ScreenshotBucketName', { value: githubScreenshot.screenshotBucket.bucket.bucketName, - description: 'Private S3 bucket hosting Vercel-preview screenshots (served via CloudFront)', + description: 'Private S3 bucket hosting preview-deploy screenshots (served via CloudFront)', }); new CfnOutput(this, 'ScreenshotCloudFrontDomain', {