diff --git a/cdk/package.json b/cdk/package.json index 2b2d5057..9d797d56 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,17 +25,21 @@ "@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.8.2", + "@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", - "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": { "@aws-cdk/integ-runner": "2.199.0", @@ -46,6 +51,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/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts new file mode 100644 index 00000000..a3696bea --- /dev/null +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -0,0 +1,292 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as path from 'path'; +import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda'; +import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; +import { ScreenshotBucket } from './screenshot-bucket'; + +/** + * Properties for GitHubScreenshotIntegration construct. + */ +export interface GitHubScreenshotIntegrationProps { + /** The existing REST API to add the GitHub webhook route to. */ + readonly api: apigw.RestApi; + + /** + * Existing GitHub PAT secret. The processor reuses ABCA's main GitHub + * token to (a) look up which PR a deploy SHA belongs to via the + * Commits API, and (b) post the screenshot comment on that PR. + * No new GitHub credential is provisioned by this construct. + */ + readonly githubTokenSecret: secretsmanager.ISecret; + + /** + * Optional — when provided, the processor also tries to post the + * screenshot to a linked Linear issue. Resolved from the GitHub PR + * title/body via a Linear-identifier regex (e.g. `ABCA-42`), then + * looked up across all `status='active'` workspaces in the registry + * via Linear's `issueVcsBranchSearch` GraphQL. + */ + readonly linearWorkspaceRegistryTable?: dynamodb.ITable; + + /** + * Removal policy for the dedup table + screenshot bucket. Defaults + * to DESTROY so dev stacks don't accumulate orphans on `cdk destroy`. + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Override for the 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; +} + +/** + * 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) + * - Private screenshot S3 bucket; served anonymously via CloudFront OAC + * - 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 { + /** Private bucket; served via CloudFront OAC. Hosts 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 (private; served via CloudFront with OAC) --- + 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) --- + // Lambda timeout: 120s. The handler enforces a single wall-clock + // deadline of 110s (TOTAL_BUDGET_MS in github-webhook-processor.ts) + // shared across PR-lookup retry + screenshot capture + S3 PUT + + // comment POST, so no individual sub-step can run past the Lambda + // timeout even on the worst-case path. The 10s headroom covers + // SDK auto-retries + the runtime's shutdown grace so a hard timeout + // never severs an in-flight comment-post. (theagenticguy PR-241 + // review item B1: previous comment under-counted the 35s retry + // ladder that runs before captureScreenshot's 60s budget.) + this.webhookProcessorFn = new lambda.NodejsFunction(this, 'WebhookProcessorFn', { + entry: path.join(handlersDir, 'github-webhook-processor.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(120), + memorySize: 512, + environment: { + SCREENSHOT_BUCKET_NAME: this.screenshotBucket.bucket.bucketName, + SCREENSHOT_PUBLIC_HOST: this.screenshotBucket.distribution.domainName, + GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, + ...(props.linearWorkspaceRegistryTable && { + LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: props.linearWorkspaceRegistryTable.tableName, + }), + }, + bundling: commonBundling, + }); + + this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn); + props.githubTokenSecret.grantRead(this.webhookProcessorFn); + + // Optional Linear feedback path. Wired only when a registry table + // is provided. The processor scans the registry for active + // workspaces, then per-workspace looks up the OAuth token from + // Secrets Manager (`bgagent-linear-oauth-*` prefix, written by + // `bgagent linear setup`). + // + // PutSecretValue is intentional, not a typo: resolveLinearOauthToken + // rotates Linear's refresh token in place when it expires (the same + // pattern as the orchestrator role). This is a write grant by + // design — see linear-oauth-resolver.ts. + 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. + // Action set scoped to the three calls the handler actually makes; + // resource is `*` because Browser sessions are ephemeral and the + // two `Connect*Stream` data-plane actions in the AWS Service + // Authorization Reference for `bedrock-agentcore` declare no + // resource types or condition keys (they require Resource:"*" + // anyway). cdk-nag IAM5 suppression annotates the resource + // wildcard. + // + // Source: AWS Service Authorization Reference for bedrock-agentcore, + // https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonbedrockagentcore.html + // - bedrock-agentcore:StartBrowserSession (Write) + // - bedrock-agentcore:StopBrowserSession (Write) + // - bedrock-agentcore:ConnectBrowserAutomationStream (Read; no resource scoping) + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:StartBrowserSession', + 'bedrock-agentcore:StopBrowserSession', + 'bedrock-agentcore:ConnectBrowserAutomationStream', + ], + resources: ['*'], + })); + + // --- Webhook receiver (verify, dedup, dispatch) --- + this.webhookFn = new lambda.NodejsFunction(this, 'WebhookFn', { + entry: path.join(handlersDir, 'github-webhook.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + GITHUB_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn, + GITHUB_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName, + GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NAME: this.webhookProcessorFn.functionName, + ...(props.screenshotTargetEnvironment && { + SCREENSHOT_TARGET_ENVIRONMENT: props.screenshotTargetEnvironment, + }), + }, + bundling: commonBundling, + }); + + this.webhookSecret.grantRead(this.webhookFn); + this.webhookDedupTable.grantReadWriteData(this.webhookFn); + this.webhookProcessorFn.grantInvoke(this.webhookFn); + + // --- API Gateway route --- + const githubResource = props.api.root.addResource('github'); + const webhookResource = githubResource.addResource('webhook'); + const webhookMethod = webhookResource.addMethod( + 'POST', + new apigw.LambdaIntegration(this.webhookFn), + { authorizationType: apigw.AuthorizationType.NONE }, + ); + + NagSuppressions.addResourceSuppressions(webhookMethod, [ + { + id: 'AwsSolutions-APIG4', + reason: 'GitHub webhook endpoint authenticates via X-Hub-Signature-256 HMAC, not Cognito — required by GitHub webhook protocol.', + }, + { + id: 'AwsSolutions-COG4', + reason: 'GitHub webhook endpoint authenticates via X-Hub-Signature-256 HMAC, not Cognito — required by GitHub webhook protocol.', + }, + ]); + + NagSuppressions.addResourceSuppressions(this.webhookFn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the standard managed policy for Lambda CloudWatch Logs writes.', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'DynamoDB grants from CDK helpers expand to table-arn/index/* wildcards; receiver only writes to the dedup table.', + }, + ], true); + + NagSuppressions.addResourceSuppressions(this.webhookProcessorFn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the standard managed policy for Lambda CloudWatch Logs writes.', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'AgentCore Browser sessions are ephemeral and have no per-resource ARN; the data-plane API requires wildcards. S3 PutObject uses CDK grant helpers that expand to bucket/* wildcards.', + }, + ], true); + + NagSuppressions.addResourceSuppressions(this.webhookSecret, [ + { + id: 'AwsSolutions-SMG4', + reason: 'GitHub webhook signing-secret rotation is owned by GitHub (operator regenerates on the GitHub side and pastes the new value here). No automated rotation Lambda needed.', + }, + ]); + } +} diff --git a/cdk/src/constructs/screenshot-bucket.ts b/cdk/src/constructs/screenshot-bucket.ts new file mode 100644 index 00000000..19418bb3 --- /dev/null +++ b/cdk/src/constructs/screenshot-bucket.ts @@ -0,0 +1,146 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Duration, RemovalPolicy } from 'aws-cdk-lib'; +import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; +import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; + +/** Lifecycle expiry for screenshot artifacts. */ +export const SCREENSHOT_TTL_DAYS = 30; + +/** + * Properties for ScreenshotBucket construct. + */ +export interface ScreenshotBucketProps { + /** + * Removal policy for the bucket + distribution. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to auto-delete objects when the bucket is removed. + * @default true + */ + readonly autoDeleteObjects?: boolean; +} + +/** + * Private S3 bucket fronted by a CloudFront distribution that serves + * screenshot PNGs to GitHub Markdown / Linear render pipelines. + * + * Why CloudFront and not a public-read bucket: the AWS account-level + * Block Public Access is on (S3 control plane refuses to attach any + * public bucket policy), and disabling it would change the security + * posture of the whole account. CloudFront with Origin Access Control + * is the AWS-recommended path for "S3 object served anonymously over + * HTTPS." Bucket stays fully private; only the distribution principal + * has GetObject. + * + * Layout (see `buildScreenshotKey` in shared/screenshot-url.ts — the + * 16-hex suffix is 64 bits of entropy so the anon URL isn't guessable + * from the public PR's owner/repo/sha): + * s3:///screenshots/_/--<16hex>.png (private) + * https://.cloudfront.net/screenshots/_/--<16hex>.png (anon) + * + * The 30-day lifecycle on the bucket is the source of truth for + * expiry — CloudFront's edge caches will see 403s after the TTL + * lapses, which is fine for stale PR comments. + */ +export class ScreenshotBucket extends Construct { + /** The underlying private S3 bucket. */ + public readonly bucket: s3.Bucket; + + /** CloudFront distribution serving the bucket anonymously. */ + public readonly distribution: cloudfront.Distribution; + + constructor(scope: Construct, id: string, props: ScreenshotBucketProps = {}) { + super(scope, id); + + this.bucket = new s3.Bucket(this, 'Bucket', { + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + lifecycleRules: [ + { + id: 'screenshot-ttl', + enabled: true, + expiration: Duration.days(SCREENSHOT_TTL_DAYS), + abortIncompleteMultipartUploadAfter: Duration.days(1), + }, + ], + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + autoDeleteObjects: props.autoDeleteObjects ?? true, + }); + + // CloudFront → S3 via Origin Access Control. The bucket policy is + // generated automatically by `S3BucketOrigin.withOriginAccessControl` + // and grants `s3:GetObject` to the distribution's CF service principal + // only — no anonymous principal in the policy, so account-level BPA + // doesn't reject it. + this.distribution = new cloudfront.Distribution(this, 'Distribution', { + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + // Screenshots are immutable per (repo, sha) — long TTL is safe + // and minimizes origin S3 requests on hot PRs. + cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, + allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD, + }, + // No alternate domain or ACM cert — the default + // *.cloudfront.net hostname is fine for a backend artifact host. + enableLogging: false, + comment: 'ABCA screenshot artifacts (private S3 + OAC)', + }); + + NagSuppressions.addResourceSuppressions(this.bucket, [ + { + id: 'AwsSolutions-S1', + reason: + 'Server access logs are not enabled for this bucket; screenshots are ephemeral artifacts (30-day TTL) embedded in GitHub PR comments. Adding access logging would generate substantial log volume for a low-value security signal.', + }, + ], true); + + NagSuppressions.addResourceSuppressions(this.distribution, [ + { + id: 'AwsSolutions-CFR1', + reason: 'No geo restrictions are needed — screenshots are referenced from GitHub.com which is global; restricting origins would break cross-region PR reviewers.', + }, + { + id: 'AwsSolutions-CFR2', + reason: 'AWS WAF is not attached to this distribution. The content is read-only PNGs of preview deploys; no app logic, no input handling, no auth — WAF would only add cost without reducing risk.', + }, + { + id: 'AwsSolutions-CFR3', + reason: 'Access logs are not enabled on the distribution for the same reason as the bucket — low-value high-volume signal for ephemeral artifacts.', + }, + { + id: 'AwsSolutions-CFR4', + reason: 'Distribution uses the default *.cloudfront.net certificate (TLSv1+ enforced by AWS). No custom domain, so no minimum-TLS-version override needed.', + }, + { + id: 'AwsSolutions-CFR7', + reason: 'OAC is in use (the construct calls `S3BucketOrigin.withOriginAccessControl`). cdk-nag misclassifies the L2 helper as an OAI deployment.', + }, + ], true); + } +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 6ecd745a..b350a084 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -295,6 +295,21 @@ export class TaskApi extends Construct { textTransformations: [{ priority: 0, type: 'NONE' }], }, }, + { + // GitHub deployment_status webhook (preview-deploy + // screenshot pipeline). The full payload (workflow run + // history + deploy URLs + deployment metadata) exceeds + // 8 KB and trips SizeRestrictions_BODY. HMAC-verified + // in Lambda. (CloudWatch BlockedRequests metric + // confirmed: SizeRestrictions_BODY fired, not RFI — + // GenericRFI_BODY has never blocked on this WebACL.) + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/github/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, ], }, }, @@ -342,6 +357,18 @@ export class TaskApi extends Construct { }, }, }, + { + notStatement: { + statement: { + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/github/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, + }, + }, ], }, }, diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts new file mode 100644 index 00000000..c387aae8 --- /dev/null +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -0,0 +1,491 @@ +/** + * 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 { + type GitHubDeploymentStatusPayload, + validateDeploymentStatusPayload, +} from './shared/github-deployment-status'; +import { postIssueComment } from './shared/linear-feedback'; +import { extractLinearIdentifier, findLinearIssueByIdentifier } from './shared/linear-issue-lookup'; +import { logger } from './shared/logger'; +import { buildScreenshotKey, encodeMarkdownUrl, isAllowedScreenshotUrl } from './shared/screenshot-url'; + +const s3 = new S3Client({}); + +const SCREENSHOT_BUCKET = process.env.SCREENSHOT_BUCKET_NAME!; +// CloudFront distribution domain — `.cloudfront.net`. Used as +// the public host for the screenshot URL embedded in PR comments. +// The bucket is private; CloudFront with OAC reads on the agent's +// behalf. +const SCREENSHOT_PUBLIC_HOST = process.env.SCREENSHOT_PUBLIC_HOST!; +const GITHUB_TOKEN_SECRET_ARN = process.env.GITHUB_TOKEN_SECRET_ARN!; +// Optional — when set, the processor also tries to post the +// screenshot comment onto a linked Linear issue. Resolved from the +// GitHub PR title/body via a Linear-identifier regex (e.g. `ABCA-42`), +// then looked up across all active workspaces in the registry. +const LINEAR_WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; + +/** + * Total wall-clock budget for the processor run. The Lambda timeout is + * 120s; we leave 10s of headroom for SDK retries + the runtime's + * shutdown grace so a hard timeout never severs a comment-post mid-flight. + * Threaded as a single deadline through PR-lookup retry + screenshot + * capture + S3 PUT + comment POST so the worst case never exceeds it. + */ +const TOTAL_BUDGET_MS = 110_000; + +/** + * Reserve carved out of the remaining budget AFTER PR lookup, BEFORE + * starting the screenshot capture. Covers S3 PUT (typically <2s) + + * GitHub PR comment POST (typically <2s) + the 2s Page settle inside + * the browser. Anything left over is the screenshot's actual budget. + */ +const POST_CAPTURE_RESERVE_MS = 8_000; + +/** + * Minimum budget we'll allow `captureScreenshot` to start with. If less + * than this remains after PR lookup, fail fast rather than start a + * session that's already doomed. + */ +const MIN_CAPTURE_BUDGET_MS = 15_000; + +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 { + // One wall-clock deadline shared across PR lookup + screenshot capture + // + S3 PUT + comment POST. Without this, findPullRequestForShaWithRetry + // could spend ~35s before captureScreenshot starts its independent 60s + // budget — totaling ~95s + S3 + comment, which exceeds the 120s Lambda + // timeout on slow-GitHub days. (theagenticguy PR-241 review item B1.) + const deadline = Date.now() + TOTAL_BUDGET_MS; + const remaining = (): number => Math.max(0, deadline - Date.now()); + + if (!event.raw_body) { + logger.error('GitHub webhook processor invoked without raw_body'); + return; + } + + let raw: GitHubDeploymentStatusPayload; + try { + raw = 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 payload = validateDeploymentStatusPayload(raw); + if (!payload) { + // The receiver runs the same validation, so this branch should be + // unreachable on the default dispatch path. Logging at warn (not + // error) so the metric stays clean if someone replays an old event. + logger.warn('Processor received invalid deployment_status payload — skipping', { + repo: raw.repository?.full_name, + deployment_id: raw.deployment?.id, + }); + return; + } + const { repoFullName: repo, sha, environmentUrl: previewUrl, deploymentId } = payload; + + // SSRF defense-in-depth: the path is HMAC-gated and AgentCore Browser + // sits outside the customer VPC, but whatever renders ends up on a + // public CloudFront URL. Reject obviously-wrong shapes (non-https, + // literal-IP, link-local, loopback) at the boundary. (theagenticguy + // PR-241 review.) + if (!isAllowedScreenshotUrl(previewUrl)) { + logger.warn('Rejected deployment_status preview URL on allowlist', { + repo, + preview_url: previewUrl, + }); + return; + } + + logger.info('Screenshot pipeline starting', { + repo, + sha, + preview_url: previewUrl, + deployment_id: deploymentId, + budget_ms: TOTAL_BUDGET_MS, + }); + + let token: string; + try { + token = await resolveGitHubToken(GITHUB_TOKEN_SECRET_ARN); + } catch (err) { + logger.error('Failed to resolve GitHub token; cannot post screenshot comment', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + // Race: 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, but cap by remaining budget so the screenshot + // half always gets at least MIN_CAPTURE_BUDGET_MS. + const prLookupBudget = Math.max(0, remaining() - POST_CAPTURE_RESERVE_MS - MIN_CAPTURE_BUDGET_MS); + const pr = await findPullRequestForShaWithRetry(repo, sha, token, prLookupBudget); + if (!pr) { + // Promote to error: "no PR after the retry budget" is the shape of + // a systematic break (deploy-without-PR, token regression, GitHub + // outage). theagenticguy review: warn-level was invisible. Add a + // tagged event_id for the CloudWatch metric filter / alarm. + logger.error('No open PR found for SHA after retries — skipping screenshot post', { + event: 'screenshot.pr_lookup_exhausted', + error_id: 'SCREENSHOT_PR_LOOKUP_EXHAUSTED', + repo, + sha, + budget_ms: prLookupBudget, + }); + return; + } + + // Confirm we have enough wall-clock left to even try a capture; if + // PR lookup ate the budget on a slow GitHub day, fail fast rather + // than start an AgentCore session that's already doomed. + const captureBudget = Math.max(0, remaining() - POST_CAPTURE_RESERVE_MS); + if (captureBudget < MIN_CAPTURE_BUDGET_MS) { + logger.error('Insufficient budget remaining for screenshot capture — skipping', { + event: 'screenshot.budget_exhausted', + error_id: 'SCREENSHOT_BUDGET_EXHAUSTED', + repo, + pr_number: pr.number, + remaining_ms: remaining(), + capture_budget_ms: captureBudget, + }); + return; + } + + let png: Uint8Array; + try { + png = await captureScreenshot(previewUrl, { timeoutMs: captureBudget }); + } catch (err) { + logger.error('Screenshot capture failed', { + event: 'screenshot.capture_failed', + error_id: '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', { + event: 'screenshot.s3_put_failed', + error_id: 'SCREENSHOT_S3_PUT_FAILED', + bucket: SCREENSHOT_BUCKET, + key, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const publicUrl = `https://${SCREENSHOT_PUBLIC_HOST}/${key}`; + const commentBody = renderCommentBody(publicUrl, previewUrl); + + try { + const result = await upsertTaskComment({ + repo, + issueOrPrNumber: pr.number, + body: commentBody, + token, + // Always POST fresh — a single PR can have multiple preview screenshots + // as the user pushes new commits, and editing the prior comment in + // place would lose the history. + existingCommentId: undefined, + }); + logger.info('Posted screenshot comment to PR', { + repo, + pr_number: pr.number, + comment_id: result.commentId, + public_url: publicUrl, + }); + } catch (err) { + // Promoted from warn → error: by this point we've already paid for + // the AgentCore session + S3 PUT, so a comment-post failure is the + // ONLY signal the operator gets that the screenshot wasn't + // delivered. tagged event_id for the CloudWatch metric filter. + // (theagenticguy PR-241 review.) + logger.error('Failed to post screenshot PR comment', { + event: 'screenshot.pr_comment_post_failed', + error_id: 'SCREENSHOT_PR_COMMENT_POST_FAILED', + repo, + pr_number: pr.number, + public_url: publicUrl, + 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)', { + event: 'screenshot.linear_comment_post_failed', + identifier, + linear_issue_id: linearIssue.issueId, + }); + } + } else { + logger.info('Linear identifier did not resolve to an issue — skipping Linear post', { + identifier, + repo, + pr_number: pr.number, + }); + } + } + } +} + +/** + * Open PR shape we extract from the GitHub commit-pulls API. Title + + * body are used downstream by the Linear issue lookup; the others go + * into log lines for debugging. + */ +interface OpenPr { + readonly number: number; + readonly title: string; + readonly body: string; +} + +/** + * Wait for an open PR to exist for the given SHA, retrying with a + * small backoff. 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. Capped by `budgetMs` so the caller can hand + * over only what it can afford to spend (B1: shared deadline). Returns + * null on exhaustion (no PR yet) or budget timeout. + */ +async function findPullRequestForShaWithRetry( + repo: string, + sha: string, + token: string, + budgetMs: number, +): Promise { + const deadline = Date.now() + budgetMs; + const delays = [0, 5_000, 10_000, 20_000]; + for (let i = 0; i < delays.length; i++) { + const delay = delays[i]; + if (delay > 0) { + // Skip the wait if the deadline would land mid-sleep. + const remaining = deadline - Date.now(); + if (remaining <= 0) return null; + await new Promise((r) => setTimeout(r, Math.min(delay, remaining))); + } + if (Date.now() >= deadline) return null; + const pr = await findPullRequestForSha(repo, sha, token); + if (pr) return pr; + const next = delays[i + 1]; + if (next !== undefined) { + logger.info('Open PR not found yet for SHA — will retry', { + repo, + sha, + next_delay_ms: next, + attempt: i + 1, + }); + } + } + return null; +} + +/** + * Look up an open PR associated with `sha`. Uses the + * "List pull requests associated with a commit" GitHub API + * (https://docs.github.com/rest/commits/commits#list-pull-requests-associated-with-a-commit). + * + * Returns the first OPEN PR (with title/body), or null if none. + * Closed/merged PRs are filtered out — v1 only screenshots active + * reviews. + */ +async function findPullRequestForSha( + repo: string, + sha: string, + token: string, +): Promise { + const url = `https://api.github.com/repos/${repo}/commits/${sha}/pulls`; + let res: Response; + // 5s per-request timeout via AbortController. Mirrors the Linear + // path, where unbounded fetches were previously blamed for budget + // overruns. Note: this is the per-attempt cap, not the total retry + // budget — the caller threads the wall-clock deadline. + const ac = new AbortController(); + const fetchTimeoutMs = 5_000; + const timer = setTimeout(() => ac.abort(), fetchTimeoutMs); + try { + res = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + signal: ac.signal, + }); + } catch (err) { + logger.warn('GitHub commit-pulls fetch failed', { + repo, + sha, + timed_out: ac.signal.aborted, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } finally { + clearTimeout(timer); + } + + if (!res.ok) { + logger.warn('GitHub commit-pulls returned non-2xx', { + repo, + sha, + status: res.status, + }); + return null; + } + + // GitHub's contract is a JSON array, but a transient 2xx HTML body or + // a malformed payload would crash an unguarded `.find` and throw out + // of the (un-DLQ'd) processor. Treat anything non-array as no-PR. + let parsed: unknown; + try { + parsed = await res.json(); + } catch (err) { + logger.warn('GitHub commit-pulls returned non-JSON body', { + repo, + sha, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + if (!Array.isArray(parsed)) { + logger.warn('GitHub commit-pulls did not return an array', { repo, sha }); + return null; + } + const pulls = parsed as Array<{ + number?: number; + state?: string; + title?: string; + body?: string | null; + }>; + const open = pulls.find((p) => p.state === 'open' && typeof p.number === 'number'); + if (!open) return null; + return { + number: open.number!, + title: open.title ?? '', + body: open.body ?? '', + }; +} + +/** Render the PR comment body. */ +function renderCommentBody(publicUrl: string, previewUrl: string): string { + // previewUrl is payload-derived; percent-encode its parens so a crafted + // path can't break out of the markdown link and inject content into a + // comment posted under ABCA's token. publicUrl is our own CloudFront key + // (no parens by construction) so it's interpolated as-is. + const safePreview = encodeMarkdownUrl(previewUrl); + return [ + '🖼️ **Preview screenshot**', + '', + `[![preview](${publicUrl})](${safePreview})`, + '', + `_From [preview link](${safePreview}) — 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 { + // previewUrl is payload-derived — see renderCommentBody for the + // markdown-breakout rationale. + const safePreview = encodeMarkdownUrl(previewUrl); + return [ + '🖼️ **Preview screenshot**', + '', + `![preview](${publicUrl})`, + '', + `[Preview link](${safePreview})`, + '', + '_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..82533863 --- /dev/null +++ b/cdk/src/handlers/github-webhook.ts @@ -0,0 +1,228 @@ +/** + * 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 { + type GitHubDeploymentStatusPayload, + validateDeploymentStatusPayload, +} from './shared/github-deployment-status'; +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; + +/** + * 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 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`: + * 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 { + 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 raw: GitHubDeploymentStatusPayload; + try { + raw = JSON.parse(event.body) as GitHubDeploymentStatusPayload; + } 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' }); + } + + // Filter pre-validate so common skip-paths return early without + // logging a "missing fields" warn for an in-progress event. + if (raw.deployment_status?.state !== 'success') { + return jsonResponse(200, { ok: true, skipped_state: raw.deployment_status?.state }); + } + + // Filter to a configured environment name. Defaults to `Preview` + // because Vercel labels per-PR deploys that way, but every provider + // uses different conventions: + // - Vercel preview: `Preview` + // - AWS Amplify Hosting: branch name (e.g. `main`, `feat/x`) + // - GitHub Actions deploys: whatever the workflow passes to + // `actions/create-deployment` + // - Netlify deploy previews: `Deploy Preview ` + // Operators on non-Vercel backends override via + // `SCREENSHOT_TARGET_ENVIRONMENT` (Lambda env var, redeploy required). + const targetEnv = process.env.SCREENSHOT_TARGET_ENVIRONMENT ?? 'Preview'; + if (raw.deployment?.environment !== targetEnv) { + return jsonResponse(200, { + ok: true, + skipped_environment: raw.deployment?.environment, + }); + } + + // GitHub sometimes fires `success` deployment_status events without + // an `environment_url` (e.g. when the provider hasn't published the + // URL yet but the build itself succeeded). 200-skip these so GitHub + // doesn't retry — the next status update will carry the URL. + if (!raw.deployment_status?.environment_url) { + return jsonResponse(200, { ok: true, skipped_no_url: true }); + } + + // Single validate call shared with the processor — guarantees the + // processor doesn't reject a payload the receiver admitted (closes + // the "missing deployment.sha" gap where the processor would drop + // events the receiver had dispatched). Runs after the state / + // environment / env-url skip-paths so 200s don't log a "missing + // fields" warn. + const payload = validateDeploymentStatusPayload(raw); + if (!payload) { + logger.warn('GitHub deployment_status webhook missing required fields', { + repo: raw.repository?.full_name, + deployment_id: raw.deployment?.id, + status_id: raw.deployment_status?.id, + sha_present: Boolean(raw.deployment?.sha), + }); + return jsonResponse(400, { error: 'Missing required deployment_status fields' }); + } + + // 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 = `${payload.repoFullName}#${payload.deploymentId}#${payload.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: payload.repoFullName, + deployment_id: payload.deploymentId, + status_id: payload.statusId, + }); + // Roll the dedup row back so GitHub's retry can try dispatch again. + try { + await ddb.send(new DeleteCommand({ + TableName: DEDUP_TABLE_NAME, + Key: { dedup_key: dedupKey }, + })); + } catch (cleanupErr) { + logger.warn('Failed to roll back GitHub webhook dedup row after invoke failure', { + error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + dedup_key: dedupKey, + }); + } + return jsonResponse(500, { error: 'Dispatch failed' }); + } + + return jsonResponse(200, { ok: true }); + } catch (err) { + logger.error('GitHub webhook handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(500, { error: 'Internal server error' }); + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/linear-webhook-processor.ts b/cdk/src/handlers/linear-webhook-processor.ts index 18ad6a27..15d2b4b3 100644 --- a/cdk/src/handlers/linear-webhook-processor.ts +++ b/cdk/src/handlers/linear-webhook-processor.ts @@ -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/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts new file mode 100644 index 00000000..66522ac4 --- /dev/null +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -0,0 +1,401 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Sha256 } from '@aws-crypto/sha256-js'; +import { + BedrockAgentCoreClient, + StartBrowserSessionCommand, + StopBrowserSessionCommand, +} from '@aws-sdk/client-bedrock-agentcore'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; +import WebSocket, { type RawData } from 'ws'; +import { logger } from './logger'; + +const REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'; + +/** + * AWS-managed default browser identifier. AgentCore Browser publishes a + * shared browser at this id without provisioning. (We could call + * `CreateBrowser` to get a dedicated one, but the screenshot path + * doesn't need any custom config — keep it simple.) + */ +const AWS_BROWSER_IDENTIFIER = 'aws.browser.v1'; + +/** + * Default budget for the entire screenshot job (start session → navigate + * → screenshot → stop). Lambda timeout should be at least 15s above this + * to leave headroom for the JSON encode + S3 PUT after the screenshot. + */ +const DEFAULT_TIMEOUT_MS = 60_000; + +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.loadEventFired` + * 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 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. + * @returns Raw PNG bytes (NOT base64-wrapped) ready for S3.PutObject. + */ +export async function captureScreenshot(url: string, opts: { timeoutMs?: number } = {}): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const client = new BedrockAgentCoreClient({ region: REGION }); + + const startResp = await client.send(new StartBrowserSessionCommand({ + browserIdentifier: AWS_BROWSER_IDENTIFIER, + name: `bgagent-screenshot-${Date.now()}`, + })); + const sessionId = startResp.sessionId; + const automationEndpoint = startResp.streams?.automationStream?.streamEndpoint; + if (!sessionId || !automationEndpoint) { + throw new Error('AgentCore Browser StartBrowserSession returned no sessionId or automation endpoint'); + } + + logger.info('AgentCore Browser session started', { + session_id: sessionId, + automation_endpoint: automationEndpoint, + }); + + try { + const png = await runCdpScreenshot(automationEndpoint, url, timeoutMs); + return png; + } finally { + try { + await client.send(new StopBrowserSessionCommand({ + browserIdentifier: AWS_BROWSER_IDENTIFIER, + sessionId, + })); + } catch (err) { + // Sessions auto-expire after ~10 minutes if we leak — log and move on. + logger.warn('Failed to stop AgentCore Browser session (will auto-expire)', { + session_id: sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } +} + +/** + * Open the automation WebSocket, drive CDP, return PNG bytes. Caller is + * responsible for the StartBrowserSession + StopBrowserSession lifecycle. + */ +async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): Promise { + // AgentCore Browser's WSS endpoint accepts SigV4 in two forms: signed + // `Authorization` headers OR signed query parameters (presigned URL). + // We use the presigned-URL form because the `Host` header sent by the + // WS upgrade (handled inside `ws`) doesn't always match what we signed + // when using header-based auth, leading to 403s. Query-param signing + // sidesteps the Host-header reconciliation entirely. + const signedUrl = await sigV4PresignWss(wssUrl); + const ws = new WebSocket(signedUrl); + + const deadline = Date.now() + timeoutMs; + const remaining = () => Math.max(0, deadline - Date.now()); + + // CDP message id allocator. Scoped to the function so concurrent + // captures (unusual but possible in tests) don't share counter state. + let nextCdpId = 1; + + // Promise machinery for tracking in-flight CDP requests by `id`. + const pending = new Map void; reject: (err: Error) => void }>(); + const eventQueue: CdpMessage[] = []; + // Each waiter has a predicate; on each incoming event we deliver to the + // FIRST waiter whose predicate matches, otherwise queue the event. + interface EventWaiter { + readonly predicate: (msg: CdpMessage) => boolean; + readonly resolve: (msg: CdpMessage) => void; + } + const eventWaiters: EventWaiter[] = []; + + ws.on('message', (raw: RawData) => { + const data = raw.toString(); + let msg: CdpMessage; + try { + msg = JSON.parse(data) as CdpMessage; + } catch { + return; + } + if (typeof msg.id === 'number') { + const slot = pending.get(msg.id); + if (slot) { + pending.delete(msg.id); + if (msg.error) { + slot.reject(new Error(`CDP error ${msg.error.code}: ${msg.error.message}`)); + } else { + slot.resolve(msg); + } + } + } else if (msg.method) { + const waiterIdx = eventWaiters.findIndex((w) => w.predicate(msg)); + if (waiterIdx !== -1) { + const [waiter] = eventWaiters.splice(waiterIdx, 1); + waiter.resolve(msg); + } else { + eventQueue.push(msg); + } + } + }); + + // Open the socket. `ws` exposes node-style EventEmitter; the + // `unexpected-response` event surfaces HTTP-level handshake failures + // (e.g. 403 from misaligned SigV4) so we can log a meaningful error + // instead of an empty `error` event. + // + // Failure paths must close the socket — without `terminate()` on the + // open-timeout path, a hung handshake leaks the underlying TCP + // connection per failed attempt (review nit, PR #241). + await new Promise((resolve, reject) => { + const onOpen = (): void => { + cleanup(); + resolve(); + }; + const onError = (err: Error): void => { + cleanup(); + try { ws.terminate(); } catch { /* socket may already be closed */ } + reject(new Error(`AgentCore Browser WebSocket error: ${err.message || '(no message)'}`)); + }; + const onUnexpectedResponse = (_req: unknown, res: { statusCode?: number }): void => { + cleanup(); + try { ws.terminate(); } catch { /* socket may already be closed */ } + reject(new Error(`AgentCore Browser WebSocket handshake failed: HTTP ${res.statusCode ?? '?'}`)); + }; + const cleanup = (): void => { + ws.removeListener('open', onOpen); + ws.removeListener('error', onError); + ws.removeListener('unexpected-response', onUnexpectedResponse); + }; + ws.on('open', onOpen); + ws.on('error', onError); + ws.on('unexpected-response', onUnexpectedResponse); + setTimeout(() => { + cleanup(); + try { ws.terminate(); } catch { /* socket may already be closed */ } + 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 targetInfos = narrowTargetInfos(targetsResp.result); + const pageTarget = targetInfos.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 = narrowSessionId(attachResp.result); + if (!pageSessionId) { + throw new Error('Target.attachToTarget did not return a sessionId'); + } + + // 3. Enable Page domain so we get the `Page.loadEventFired` event + // we wait on below. + 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 = narrowNavigateError(navResp.result); + 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 + // typical preview URLs (Vercel/Netlify/Amplify CDN edges) this + // is 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 = narrowScreenshotData(shotResp.result); + if (!base64) { + throw new Error('Page.captureScreenshot returned no data'); + } + return Buffer.from(base64, 'base64'); + } finally { + try { ws.close(); } catch { /* ignore */ } + } +} + +/** + * Presign the WSS URL with SigV4 query parameters. AgentCore Browser + * accepts auth either as headers on the upgrade GET or as query params + * on the URL itself; the latter is more robust through WebSocket + * clients that rewrite Host headers (e.g. `ws`). + * + * Returns a `wss://...?X-Amz-Algorithm=...&X-Amz-Credential=...&...` + * URL ready to pass straight to `new WebSocket(...)`. + */ +async function sigV4PresignWss(wssUrl: string): Promise { + const u = new URL(wssUrl); + const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region: REGION, + credentials: defaultProvider(), + sha256: Sha256, + applyChecksum: false, + }); + + // Convert wss:// → https:// for the signing request (SigV4 doesn't + // know about wss). The signature is over the path + query, so the + // protocol on the signed request is irrelevant — we paste the auth + // params back onto the original wss:// URL. + const queryEntries = Array.from(u.searchParams.entries()); + const query: Record = {}; + for (const [k, v] of queryEntries) query[k] = v; + + const req = new HttpRequest({ + method: 'GET', + protocol: 'https:', + hostname: u.hostname, + path: u.pathname, + query, + headers: { host: u.hostname }, + }); + + // 60s expiry is fine — we open the socket immediately after signing. + const presigned = await signer.presign(req, { expiresIn: 60 }); + const out = new URL(wssUrl); + for (const [k, v] of Object.entries(presigned.query ?? {})) { + out.searchParams.set(k, Array.isArray(v) ? v[0] : (v as string)); + } + return out.toString(); +} + +/** + * Type-narrow helpers for CDP response shapes. Replaces inline `as` + * casts with checked accessors so a malformed response is logged as + * `null`/`undefined` rather than silently miscoerced. (theagenticguy + * PR-241 review: reduce unchecked casts in CDP plumbing.) + */ +interface TargetInfo { + readonly targetId: string; + readonly type: string; + readonly url: string; +} + +function narrowTargetInfos(result: Record | undefined): TargetInfo[] { + const infos = result?.targetInfos; + if (!Array.isArray(infos)) return []; + return infos.filter((t): t is TargetInfo => + typeof t === 'object' && t !== null + && typeof (t as Record).targetId === 'string' + && typeof (t as Record).type === 'string' + && typeof (t as Record).url === 'string', + ); +} + +function narrowSessionId(result: Record | undefined): string | undefined { + const id = result?.sessionId; + return typeof id === 'string' ? id : undefined; +} + +function narrowNavigateError(result: Record | undefined): string | undefined { + const err = result?.errorText; + return typeof err === 'string' ? err : undefined; +} + +function narrowScreenshotData(result: Record | undefined): string | undefined { + const data = result?.data; + return typeof data === 'string' ? data : undefined; +} diff --git a/cdk/src/handlers/shared/github-deployment-status.ts b/cdk/src/handlers/shared/github-deployment-status.ts new file mode 100644 index 00000000..6c3bf6d6 --- /dev/null +++ b/cdk/src/handlers/shared/github-deployment-status.ts @@ -0,0 +1,107 @@ +/** + * 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. + */ + +/** + * Subset of GitHub's `deployment_status` webhook payload that the + * screenshot pipeline reads. Shared between the receiver (HMAC verify, + * filter, dedup) and the processor (capture + post). Single source of + * truth so the two sides can't drift on field shape — and so the + * receiver-side filter and the processor-side reads agree on what's + * required. + * + * 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. (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`: 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) + */ +export interface GitHubDeploymentStatusPayload { + readonly action?: string; + readonly deployment_status?: { + readonly id?: number; + readonly state?: string; + readonly environment_url?: string; + }; + readonly deployment?: { + readonly id?: number; + readonly sha?: string; + readonly environment?: string; + }; + readonly repository?: { + readonly full_name?: string; + }; +} + +/** + * Validated `deployment_status` payload — every field the processor + * requires to do useful work is present and non-empty. Returned by + * `validateDeploymentStatusPayload` so callers can stop carrying + * `?` everywhere downstream. + */ +export interface ValidatedDeploymentStatusPayload { + readonly state: string; + readonly statusId: number; + readonly environmentUrl: string; + readonly deploymentId: number; + readonly sha: string; + readonly environment: string; + readonly repoFullName: string; +} + +/** + * Narrow a raw deployment_status envelope into a fully-validated shape. + * Returns null when any required field is missing, so the receiver and + * processor share one validation contract instead of duplicating + * presence checks. Callers that 200-skip on missing fields stay + * responsible for their own logging / response. + */ +export function validateDeploymentStatusPayload( + raw: GitHubDeploymentStatusPayload, +): ValidatedDeploymentStatusPayload | null { + const state = raw.deployment_status?.state; + const statusId = raw.deployment_status?.id; + const environmentUrl = raw.deployment_status?.environment_url; + const deploymentId = raw.deployment?.id; + const sha = raw.deployment?.sha; + const environment = raw.deployment?.environment; + const repoFullName = raw.repository?.full_name; + + if ( + typeof state !== 'string' || state.length === 0 + || typeof statusId !== 'number' + || typeof environmentUrl !== 'string' || environmentUrl.length === 0 + || typeof deploymentId !== 'number' + || typeof sha !== 'string' || sha.length === 0 + || typeof environment !== 'string' || environment.length === 0 + || typeof repoFullName !== 'string' || repoFullName.length === 0 + ) { + return null; + } + + return { state, statusId, environmentUrl, deploymentId, sha, environment, repoFullName }; +} 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..5ecceac8 --- /dev/null +++ b/cdk/src/handlers/shared/github-webhook-verify.ts @@ -0,0 +1,143 @@ +/** + * 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 })); + // Treat empty / whitespace-only SecretString as null. If an operator + // ever wrote `""` out of band, `crypto.createHmac('sha256', '')` would + // happily run and an attacker who computes `HMAC('', body)` would pass. + // The default CDK Secret resource generates a random value so this + // isn't reachable on the default config — but matching the + // fail-closed-on-risk tenet is cheap. (theagenticguy PR-241 review B2.) + const value = result.SecretString; + if (!value || value.trim() === '') { + logger.error('GitHub webhook secret is empty — refusing to use for HMAC', { + secret_id: secretId, + }); + secretCache.delete(secretId); + return null; + } + secretCache.set(secretId, { secret: value, expiresAt: now + CACHE_TTL_MS }); + return value; + } 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 { + // Defense-in-depth: getGitHubWebhookSecret already filters empty + // secrets, but if a future caller wires a different secret source we + // still want HMAC('') rejected. (theagenticguy PR-241 review B2.) + if (!webhookSecret || webhookSecret.trim() === '') { + return false; + } + if (!header.startsWith('sha256=')) { + return false; + } + const provided = header.slice('sha256='.length); + const expected = crypto.createHmac('sha256', webhookSecret).update(body).digest('hex'); + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided)); + } catch (err) { + logger.warn('GitHub signature comparison failed', { + error: err instanceof Error ? err.message : String(err), + expected_length: expected.length, + provided_length: provided.length, + }); + return false; + } +} + +/** + * Verify a GitHub webhook request, with one transparent re-fetch on + * cache miss. Same UX as `verifyLinearRequest` so warm Lambdas don't + * silently reject post-rotation deliveries for up to 5 minutes. + */ +export async function verifyGitHubRequest(secretId: string, header: string, body: string): Promise { + const cached = await getGitHubWebhookSecret(secretId); + if (cached && verifyGitHubSignature(cached, header, body)) { + return true; + } + + invalidateGitHubWebhookSecretCache(secretId); + const fresh = await getGitHubWebhookSecret(secretId, true); + if (!fresh) return false; + if (fresh === cached) return false; + return verifyGitHubSignature(fresh, header, body); +} diff --git a/cdk/src/handlers/shared/linear-issue-lookup.ts b/cdk/src/handlers/shared/linear-issue-lookup.ts new file mode 100644 index 00000000..4ce4a6bd --- /dev/null +++ b/cdk/src/handlers/shared/linear-issue-lookup.ts @@ -0,0 +1,187 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { resolveLinearOauthToken } from './linear-oauth-resolver'; +import { logger } from './logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +/** + * Linear issue identifier shape, e.g. `ABCA-42`. Linear identifiers are + * `-` where the key is uppercase letters and digits is + * a positive integer. We bound the team key length [1,10] and number + * length [1,8] to avoid pathological inputs. + */ +const LINEAR_IDENTIFIER_RE = /\b([A-Z][A-Z0-9]{0,9})-(\d{1,8})\b/g; + +/** + * Pull the first Linear issue identifier (e.g. `ABCA-42`) found in + * the given text. PR titles and bodies typically include this either + * because the agent's task_description carries the identifier, or + * because Linear's own GitHub integration auto-injects an + * `ABCA-42 ` reference. + * + * Returns the first match in document order. If multiple distinct + * identifiers are present we still return the first — multi-issue PRs + * are unusual enough that single-screenshot-per-issue is acceptable. + */ +export function extractLinearIdentifier(text: string | null | undefined): string | null { + if (!text) return null; + const match = LINEAR_IDENTIFIER_RE.exec(text); + // The regex has the `g` flag for testability; reset lastIndex so + // back-to-back calls behave correctly. + LINEAR_IDENTIFIER_RE.lastIndex = 0; + return match ? `${match[1]}-${match[2]}` : null; +} + +/** + * Resolved Linear issue location, paired with the workspace that owns + * it. The screenshot processor uses these to construct a + * LinearFeedbackContext + issueId for postIssueComment. + */ +export interface LinearIssueLocation { + readonly issueId: string; + readonly linearWorkspaceId: string; + readonly workspaceSlug: string; +} + +const ISSUE_BY_IDENTIFIER_QUERY = ` +query IssueByIdentifier($identifier: String!) { + issueVcsBranchSearch(branchName: $identifier) { + id + identifier + } +} +`.trim(); + +/** + * Look up a Linear issue by identifier (e.g. `ABCA-42`) by iterating + * over every active workspace in the registry until one returns a + * match. Returns the first hit. + * + * For v1 this scan is cheap — typical deployments have 1-2 workspaces. + * If a stack ever onboards many workspaces sharing identifier prefixes, + * a followup can store team_key prefixes on the registry row and route + * directly. Until then, linear-time iteration is fine. + * + * @param identifier `ABCA-42`-style Linear issue identifier + * @param registryTableName name of LinearWorkspaceRegistryTable + * @returns issue location, or null if no workspace contains the issue + */ +export async function findLinearIssueByIdentifier( + identifier: string, + registryTableName: string, +): Promise { + let active: Array<{ linear_workspace_id: string; workspace_slug: string }> = []; + try { + const scanResp = await ddb.send(new ScanCommand({ + TableName: registryTableName, + FilterExpression: '#s = :active', + ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { ':active': 'active' }, + })); + active = (scanResp.Items ?? []).map((item) => ({ + linear_workspace_id: item.linear_workspace_id as string, + workspace_slug: item.workspace_slug as string, + })); + } catch (err) { + logger.warn('Linear issue lookup: failed to scan workspace registry', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (active.length === 0) { + logger.info('Linear issue lookup: no active workspaces in registry', { identifier }); + return null; + } + + for (const ws of active) { + const resolved = await resolveLinearOauthToken(ws.linear_workspace_id, registryTableName); + if (!resolved) continue; + + const found = await queryIssueByIdentifier(resolved.accessToken, identifier); + if (found) { + return { + issueId: found, + linearWorkspaceId: ws.linear_workspace_id, + workspaceSlug: ws.workspace_slug, + }; + } + } + return null; +} + +/** + * Issue the GraphQL query to Linear; return the issue UUID on hit, null + * on miss. Never throws — caller iterates onto the next workspace. + * + * Uses `issueVcsBranchSearch` because it accepts the human-readable + * identifier directly (the regular `issue(id:)` query needs a UUID, + * which we don't have yet). The branch-search API was designed for + * exactly this — VCS integrations resolving `-` strings to + * issue rows. + */ +async function queryIssueByIdentifier(accessToken: string, identifier: string): Promise { + let resp: Response; + try { + resp = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: ISSUE_BY_IDENTIFIER_QUERY, + variables: { identifier }, + }), + }); + } catch (err) { + logger.warn('Linear issue lookup: graphql request failed', { + identifier, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (!resp.ok) { + logger.warn('Linear issue lookup: graphql non-2xx', { identifier, status: resp.status }); + return null; + } + + const body = (await resp.json()) as { + data?: { issueVcsBranchSearch?: { id?: string; identifier?: string } | null }; + errors?: unknown; + }; + if (body.errors) { + logger.warn('Linear issue lookup: graphql errors', { identifier, errors: body.errors }); + return null; + } + const hit = body.data?.issueVcsBranchSearch; + if (!hit?.id) return null; + // Sanity: the response identifier must match what we asked for. + // `issueVcsBranchSearch` is a fuzzy match against branch-name patterns; + // exact-match the identifier to avoid linking to a near-neighbor issue. + if (hit.identifier && hit.identifier.toUpperCase() !== identifier.toUpperCase()) { + return null; + } + return hit.id; +} diff --git a/cdk/src/handlers/shared/screenshot-url.ts b/cdk/src/handlers/shared/screenshot-url.ts new file mode 100644 index 00000000..b8b4e838 --- /dev/null +++ b/cdk/src/handlers/shared/screenshot-url.ts @@ -0,0 +1,123 @@ +/** + * 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'; + +/** + * Decide whether a `deployment_status.environment_url` is safe to navigate. + * + * Defense-in-depth — the path is HMAC-gated and AgentCore Browser runs + * outside the Lambda VPC, so this isn't blocking an exploit on the + * default config. But we publish whatever renders to a public-read + * CloudFront URL, which amplifies any read; rejecting obviously-wrong + * shapes at the boundary is cheap and matches the "fail-closed on risk" + * tenet. + * + * Rejects: + * - Non-https schemes (http, file, data, javascript, ftp, …) + * - localhost / *.localhost + * - ANY IPv6 literal (bracketed host or a `:` in the host) — covers + * loopback `::1`, link-local `fe80::/10`, unique-local `fc00::/7`, + * NAT64, and IPv4-mapped forms in one rule, since preview URLs are + * always DNS names + * - Any IPv4 dotted-quad literal (the WHATWG parser normalizes + * decimal/octal/hex integer forms to dotted-quad first, so those are + * caught too) — covers RFC1918, loopback, and link-local 169.254.x.x + */ +export function isAllowedScreenshotUrl(rawUrl: string): boolean { + let parsed: URL; + try { + parsed = new URL(rawUrl); + } catch { + return false; + } + + if (parsed.protocol !== 'https:') return false; + + // Node's URL keeps IPv6 literals wrapped in `[…]` on .hostname; + // strip them so the checks below match against the bare address. + const rawHost = parsed.hostname.toLowerCase(); + const hostname = rawHost.startsWith('[') && rawHost.endsWith(']') + ? rawHost.slice(1, -1) + : rawHost; + if (hostname === '' || hostname === 'localhost' || hostname.endsWith('.localhost')) { + return false; + } + + // Reject ANY IPv6 literal rather than enumerate ranges. Preview URLs + // always use DNS names, so a bracketed host (or any host containing a + // `:`) is never a legitimate target — and enumerating ranges missed + // unique-local `fc00::/7` (e.g. `[fc00::1]`), NAT64, and IPv4-mapped + // forms. A colon is the unambiguous IPv6 signal: DNS hostnames can't + // contain one, and the port has already been split off onto + // `parsed.port`. (krokoko PR-241 round-3 finding 2.) + if (rawHost.startsWith('[') || hostname.includes(':')) return false; + + // IPv4 literals: reject any dotted-quad (preview URLs come from DNS). + // Decimal/octal/hex integer forms (e.g. `2130706433`) are already + // normalized to dotted-quad by the WHATWG URL parser, so this catches + // them too. + const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (ipv4Match) return false; + + return true; +} + +/** + * Build an unguessable S3 key for the screenshot PNG. + * + * The bucket is private and CloudFront serves anonymously, but the URL + * `https://.cloudfront.net/screenshots/_/.png` + * is enumerable from a public PR (the SHA appears in the merge UI). + * Preview deploys can render PII; we want guessing the URL to be hard. + * + * High-entropy suffix (16 hex chars = 64 bits) added between sha and + * .png. Keeps the prefix structure so per-org/repo lifecycle policies + * still work, and stays anonymous-cacheable. + */ +export function buildScreenshotKey(repo: string, sha: string, deploymentId?: number): string { + const repoSlug = repo.replaceAll('/', '_'); + const id = deploymentId !== undefined ? `-${deploymentId}` : ''; + // 8 random bytes → 16 hex chars; 64 bits of entropy. crypto.randomBytes + // is sync but cheap (< 1ms on Lambda) and avoids pulling in async + // randomness machinery for one call per invocation. + const suffix = crypto.randomBytes(8).toString('hex'); + return `screenshots/${repoSlug}/${sha}${id}-${suffix}.png`; +} + +/** + * Percent-encode the parens in a URL before it's interpolated into a + * markdown link/image target. + * + * `environment_url` comes from the webhook payload. Its hostname passes + * `isAllowedScreenshotUrl`, but the WHATWG URL parser preserves `(` and + * `)` in the path/query — so a value like + * `https://preview.vercel.app/x)](https://evil/a.png)` stays "allowed" + * yet closes the `](…)` of the comment markdown early, injecting + * attacker-chosen content into a comment posted under ABCA's token. In + * fork-PR configs the preview path can be author-influenced without the + * webhook secret, so this is reachable. + * + * `(` → `%28`, `)` → `%29` are valid percent-escapes the browser decodes + * back, so the rendered link still resolves to the real preview URL — + * it just can't break out of the markdown delimiters. + */ +export function encodeMarkdownUrl(rawUrl: string): string { + return rawUrl.replaceAll('(', '%28').replaceAll(')', '%29'); +} diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 07ac15b3..e2718cdb 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'; @@ -837,6 +838,44 @@ 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 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, + // When the screenshot lands on a PR linked to a Linear issue + // (identifier in the PR title/body), also post the screenshot + // as a comment on that Linear issue. Wired through the existing + // workspace registry so token resolution reuses the per-workspace + // OAuth secrets created by `bgagent linear setup`. + linearWorkspaceRegistryTable: linearIntegration.workspaceRegistryTable, + }); + + new CfnOutput(this, 'GitHubWebhookUrl', { + value: `${taskApi.api.url}github/webhook`, + description: 'URL to configure as the GitHub webhook target on demo repos (deployment_status events)', + }); + + new CfnOutput(this, 'GitHubWebhookSecretArn', { + value: githubScreenshot.webhookSecret.secretArn, + description: 'Secrets Manager ARN for the GitHub webhook signing secret — paste GitHub\'s value here after configuring the webhook', + }); + + new CfnOutput(this, 'ScreenshotBucketName', { + value: githubScreenshot.screenshotBucket.bucket.bucketName, + description: 'Private S3 bucket hosting preview-deploy screenshots (served via CloudFront)', + }); + + new CfnOutput(this, 'ScreenshotCloudFrontDomain', { + value: githubScreenshot.screenshotBucket.distribution.domainName, + description: 'CloudFront domain that serves the screenshot bucket anonymously to GitHub PR / Linear renders', + }); + // --- Bedrock model invocation logging (account-level) --- const invocationLogGroup = new logs.LogGroup(this, 'ModelInvocationLogGroup', { logGroupName: `/aws/bedrock/model-invocation-logs/${this.stackName}`, diff --git a/cdk/test/constructs/screenshot-bucket.test.ts b/cdk/test/constructs/screenshot-bucket.test.ts new file mode 100644 index 00000000..7cba2e65 --- /dev/null +++ b/cdk/test/constructs/screenshot-bucket.test.ts @@ -0,0 +1,83 @@ +/** + * 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 { App, Stack } from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { ScreenshotBucket } from '../../src/constructs/screenshot-bucket'; + +describe('ScreenshotBucket', () => { + let template: Template; + + beforeEach(() => { + const app = new App(); + const stack = new Stack(app, 'TestStack'); + new ScreenshotBucket(stack, 'ScreenshotBucket'); + template = Template.fromStack(stack); + }); + + // theagenticguy PR-241 review B3 + repo hygiene: lock in the + // fully-private posture so a future "simplify" doesn't drop + // BlockPublicAccess. The synth-time assertion is the cheapest way to + // catch a regression before it hits the deployed stack. + test('S3 bucket has all-true BlockPublicAccess (fully private)', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + PublicAccessBlockConfiguration: { + BlockPublicAcls: true, + BlockPublicPolicy: true, + IgnorePublicAcls: true, + RestrictPublicBuckets: true, + }, + }); + }); + + test('S3 bucket has SSE-S3 encryption', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + BucketEncryption: { + ServerSideEncryptionConfiguration: [ + { + ServerSideEncryptionByDefault: { + SSEAlgorithm: 'AES256', + }, + }, + ], + }, + }); + }); + + test('exposes a CloudFront distribution as the public host', () => { + // CloudFront serves anonymously via OAC; the bucket itself stays + // private. Asserting both pieces exist is enough — exact OAC config + // is a CDK construct internal we trust. + template.resourceCountIs('AWS::CloudFront::Distribution', 1); + template.resourceCountIs('AWS::CloudFront::OriginAccessControl', 1); + }); + + test('S3 bucket has a 30-day lifecycle on screenshots/ prefix', () => { + template.hasResourceProperties('AWS::S3::Bucket', { + LifecycleConfiguration: { + Rules: [ + { + ExpirationInDays: 30, + Status: 'Enabled', + }, + ], + }, + }); + }); +}); diff --git a/cdk/test/handlers/linear-webhook-processor.test.ts b/cdk/test/handlers/linear-webhook-processor.test.ts index f93bfb2e..533e4314 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-bgagent', name: 'bgagent' }], }, ...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-bgagent', '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 diff --git a/cdk/test/handlers/shared/github-deployment-status.test.ts b/cdk/test/handlers/shared/github-deployment-status.test.ts new file mode 100644 index 00000000..d88859d8 --- /dev/null +++ b/cdk/test/handlers/shared/github-deployment-status.test.ts @@ -0,0 +1,96 @@ +/** + * 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 { + type GitHubDeploymentStatusPayload, + validateDeploymentStatusPayload, +} from '../../../src/handlers/shared/github-deployment-status'; + +// The payload interface is deeply `readonly`, so each case constructs a +// fresh literal from parts rather than mutating a shared fixture. +function build(parts: { + status?: { id?: number; state?: string; environment_url?: string }; + deployment?: { id?: number; sha?: string; environment?: string }; + repository?: { full_name?: string }; +}): GitHubDeploymentStatusPayload { + return { + deployment_status: parts.status, + deployment: parts.deployment, + repository: parts.repository, + }; +} + +const FULL = { + status: { id: 99, state: 'success', environment_url: 'https://preview.example.com' }, + deployment: { id: 42, sha: 'abc1234', environment: 'Preview' }, + repository: { full_name: 'owner/repo' }, +}; + +describe('validateDeploymentStatusPayload', () => { + test('returns the narrowed shape when every required field is present', () => { + expect(validateDeploymentStatusPayload(build(FULL))).toEqual({ + state: 'success', + statusId: 99, + environmentUrl: 'https://preview.example.com', + deploymentId: 42, + sha: 'abc1234', + environment: 'Preview', + repoFullName: 'owner/repo', + }); + }); + + test('non-success states still validate (state filtering is the caller’s job)', () => { + // The validator only checks presence/type, not the value — the + // receiver applies the `success` filter separately before calling. + const raw = build({ ...FULL, status: { ...FULL.status, state: 'failure' } }); + expect(validateDeploymentStatusPayload(raw)?.state).toBe('failure'); + }); + + test('statusId of 0 is a valid id (checked by type, not truthiness)', () => { + const raw = build({ ...FULL, status: { ...FULL.status, id: 0 } }); + expect(validateDeploymentStatusPayload(raw)?.statusId).toBe(0); + }); + + // Each case drops or empties exactly one required field; all reject. + const rejects: Array<[string, GitHubDeploymentStatusPayload]> = [ + ['missing state', build({ ...FULL, status: { id: 99, environment_url: 'https://x' } })], + ['empty state', build({ ...FULL, status: { ...FULL.status, state: '' } })], + ['missing statusId', build({ ...FULL, status: { state: 'success', environment_url: 'https://x' } })], + ['missing environmentUrl', build({ ...FULL, status: { id: 99, state: 'success' } })], + ['empty environmentUrl', build({ ...FULL, status: { ...FULL.status, environment_url: '' } })], + ['missing deploymentId', build({ ...FULL, deployment: { sha: 'abc1234', environment: 'Preview' } })], + ['missing sha', build({ ...FULL, deployment: { id: 42, environment: 'Preview' } })], + ['empty sha', build({ ...FULL, deployment: { ...FULL.deployment, sha: '' } })], + ['missing environment', build({ ...FULL, deployment: { id: 42, sha: 'abc1234' } })], + ['empty environment', build({ ...FULL, deployment: { ...FULL.deployment, environment: '' } })], + ['missing repoFullName', build({ ...FULL, repository: {} })], + ['empty repoFullName', build({ ...FULL, repository: { full_name: '' } })], + ['absent deployment_status object', build({ deployment: FULL.deployment, repository: FULL.repository })], + ['absent deployment object', build({ status: FULL.status, repository: FULL.repository })], + ['absent repository object', build({ status: FULL.status, deployment: FULL.deployment })], + ]; + + test.each(rejects)('rejects when %s', (_label, raw) => { + expect(validateDeploymentStatusPayload(raw)).toBeNull(); + }); + + test('rejects a wholly empty envelope', () => { + expect(validateDeploymentStatusPayload({})).toBeNull(); + }); +}); diff --git a/cdk/test/handlers/shared/github-webhook-verify.test.ts b/cdk/test/handlers/shared/github-webhook-verify.test.ts new file mode 100644 index 00000000..0f7050b3 --- /dev/null +++ b/cdk/test/handlers/shared/github-webhook-verify.test.ts @@ -0,0 +1,184 @@ +/** + * 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'; + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), +})); + +import { + getGitHubWebhookSecret, + invalidateGitHubWebhookSecretCache, + verifyGitHubRequest, + verifyGitHubSignature, +} from '../../../src/handlers/shared/github-webhook-verify'; + +const SECRET_ID = 'arn:aws:secretsmanager:us-east-1:123:secret:gh-webhook'; +const SECRET_VALUE = 'super-secret'; + +function signed(body: string, key = SECRET_VALUE): string { + return 'sha256=' + crypto.createHmac('sha256', key).update(body).digest('hex'); +} + +describe('verifyGitHubSignature', () => { + test('accepts a valid sha256 signature', () => { + const body = '{"hello":"world"}'; + expect(verifyGitHubSignature(SECRET_VALUE, signed(body), body)).toBe(true); + }); + + test('rejects when signature mismatches', () => { + const body = '{"hello":"world"}'; + expect(verifyGitHubSignature('wrong', signed(body), body)).toBe(false); + }); + + test('rejects header missing the sha256= prefix (e.g. legacy sha1=)', () => { + const body = '{"hello":"world"}'; + const sha1 = 'sha1=' + crypto.createHmac('sha1', SECRET_VALUE).update(body).digest('hex'); + expect(verifyGitHubSignature(SECRET_VALUE, sha1, body)).toBe(false); + }); + + test('rejects when provided digest is the wrong byte length (timingSafeEqual would throw)', () => { + const body = '{"hello":"world"}'; + expect(verifyGitHubSignature(SECRET_VALUE, 'sha256=deadbeef', body)).toBe(false); + }); + + test('rejects when the body has been tampered', () => { + const sig = signed('{"hello":"world"}'); + expect(verifyGitHubSignature(SECRET_VALUE, sig, '{"hello":"WORLD"}')).toBe(false); + }); + + // theagenticguy PR-241 review B2: the headline empty-secret fail-open guard. + // HMAC('', body) was previously accepted by `crypto.createHmac` and would + // pass `timingSafeEqual` if the attacker computed the same HMAC. + test('rejects empty webhookSecret even with a syntactically-valid signature', () => { + const body = '{"hello":"world"}'; + const empty = 'sha256=' + crypto.createHmac('sha256', '').update(body).digest('hex'); + expect(verifyGitHubSignature('', empty, body)).toBe(false); + }); + + test('rejects whitespace-only webhookSecret', () => { + const body = '{"hello":"world"}'; + const ws = 'sha256=' + crypto.createHmac('sha256', ' ').update(body).digest('hex'); + expect(verifyGitHubSignature(' ', ws, body)).toBe(false); + }); +}); + +describe('getGitHubWebhookSecret', () => { + beforeEach(() => { + smSend.mockReset(); + invalidateGitHubWebhookSecretCache(SECRET_ID); + }); + + test('returns the secret string and caches it', async () => { + smSend.mockResolvedValueOnce({ SecretString: SECRET_VALUE }); + const v1 = await getGitHubWebhookSecret(SECRET_ID); + const v2 = await getGitHubWebhookSecret(SECRET_ID); + expect(v1).toBe(SECRET_VALUE); + expect(v2).toBe(SECRET_VALUE); + expect(smSend).toHaveBeenCalledTimes(1); + }); + + test('forceRefresh bypasses the cache', async () => { + smSend + .mockResolvedValueOnce({ SecretString: SECRET_VALUE }) + .mockResolvedValueOnce({ SecretString: 'rotated' }); + await getGitHubWebhookSecret(SECRET_ID); + const v2 = await getGitHubWebhookSecret(SECRET_ID, true); + expect(v2).toBe('rotated'); + expect(smSend).toHaveBeenCalledTimes(2); + }); + + test('returns null and drops cache entry when SecretString is missing', async () => { + smSend.mockResolvedValueOnce({}); + expect(await getGitHubWebhookSecret(SECRET_ID)).toBeNull(); + smSend.mockResolvedValueOnce({ SecretString: SECRET_VALUE }); + expect(await getGitHubWebhookSecret(SECRET_ID)).toBe(SECRET_VALUE); + expect(smSend).toHaveBeenCalledTimes(2); + }); + + // theagenticguy PR-241 review B2: empty-secret fails closed at the + // fetch layer, not just the verify layer. An operator who wrote `""` + // out of band must not have it cached and used. + test('returns null when SecretString is the empty string', async () => { + smSend.mockResolvedValueOnce({ SecretString: '' }); + expect(await getGitHubWebhookSecret(SECRET_ID)).toBeNull(); + }); + + test('returns null when SecretString is whitespace-only', async () => { + smSend.mockResolvedValueOnce({ SecretString: ' \n\t ' }); + expect(await getGitHubWebhookSecret(SECRET_ID)).toBeNull(); + }); + + test('returns null on ResourceNotFoundException', async () => { + const err = new Error('not found') as Error & { name: string }; + err.name = 'ResourceNotFoundException'; + smSend.mockRejectedValueOnce(err); + expect(await getGitHubWebhookSecret(SECRET_ID)).toBeNull(); + }); + + test('rethrows on transient SM errors so callers can fail-closed', async () => { + smSend.mockRejectedValueOnce(new Error('throttled')); + await expect(getGitHubWebhookSecret(SECRET_ID)).rejects.toThrow('throttled'); + }); +}); + +describe('verifyGitHubRequest (cache + transparent re-fetch)', () => { + beforeEach(() => { + smSend.mockReset(); + invalidateGitHubWebhookSecretCache(SECRET_ID); + }); + + test('verifies on first try when cached secret matches', async () => { + smSend.mockResolvedValueOnce({ SecretString: SECRET_VALUE }); + const body = '{"event":"deployment_status"}'; + expect(await verifyGitHubRequest(SECRET_ID, signed(body), body)).toBe(true); + }); + + test('re-fetches and retries on signature mismatch (post-rotation path)', async () => { + smSend + .mockResolvedValueOnce({ SecretString: 'old-secret' }) + .mockResolvedValueOnce({ SecretString: 'new-secret' }); + const body = '{"event":"deployment_status"}'; + const sig = signed(body, 'new-secret'); + expect(await verifyGitHubRequest(SECRET_ID, sig, body)).toBe(true); + expect(smSend).toHaveBeenCalledTimes(2); + }); + + test('returns false when refresh returns identical secret (no real rotation)', async () => { + smSend + .mockResolvedValueOnce({ SecretString: 'old-secret' }) + .mockResolvedValueOnce({ SecretString: 'old-secret' }); + const body = '{"event":"deployment_status"}'; + const sig = signed(body, 'definitely-not-the-secret'); + expect(await verifyGitHubRequest(SECRET_ID, sig, body)).toBe(false); + }); + + test('returns false when the stored secret is empty (B2 end-to-end)', async () => { + smSend + .mockResolvedValueOnce({ SecretString: '' }) + .mockResolvedValueOnce({ SecretString: '' }); + const body = '{"event":"deployment_status"}'; + // An attacker who knows the secret is empty would compute HMAC('', body). + const forged = 'sha256=' + crypto.createHmac('sha256', '').update(body).digest('hex'); + expect(await verifyGitHubRequest(SECRET_ID, forged, body)).toBe(false); + }); +}); diff --git a/cdk/test/handlers/shared/linear-issue-lookup.test.ts b/cdk/test/handlers/shared/linear-issue-lookup.test.ts new file mode 100644 index 00000000..47042fd3 --- /dev/null +++ b/cdk/test/handlers/shared/linear-issue-lookup.test.ts @@ -0,0 +1,81 @@ +/** + * 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. + */ + +// `findLinearIssueByIdentifier` is covered by PR #275's broader test +// suite (mocks the registry scan + GraphQL); this file only locks in +// `extractLinearIdentifier` since it's a pure function and the g-flag +// regex's `lastIndex` reset behavior is exactly the kind of thing that +// breaks silently between releases. (theagenticguy PR-241 review.) + +import { extractLinearIdentifier } from '../../../src/handlers/shared/linear-issue-lookup'; + +describe('extractLinearIdentifier', () => { + test('returns null for null / undefined / empty input', () => { + expect(extractLinearIdentifier(null)).toBeNull(); + expect(extractLinearIdentifier(undefined)).toBeNull(); + expect(extractLinearIdentifier('')).toBeNull(); + }); + + test('extracts a Linear identifier from PR title shape', () => { + expect(extractLinearIdentifier('feat(linear): ABCA-42 do the thing')) + .toBe('ABCA-42'); + }); + + test('extracts a Linear identifier from PR body shape', () => { + expect(extractLinearIdentifier('Closes ABCA-42\n\nSummary…')).toBe('ABCA-42'); + }); + + test('returns the FIRST identifier when multiple are present', () => { + expect(extractLinearIdentifier('ABCA-42 supersedes PLAT-9')).toBe('ABCA-42'); + }); + + test('does not match lowercase team key prefixes', () => { + expect(extractLinearIdentifier('see issue abca-42 for details')).toBeNull(); + }); + + test('does not match identifiers without a dash', () => { + expect(extractLinearIdentifier('ABCA42')).toBeNull(); + }); + + test('does not match identifiers with too long a number tail', () => { + // Bound is 1-8 digits in the regex; 9+ shouldn't be admitted. + expect(extractLinearIdentifier('ABCA-1234567890')).toBeNull(); + }); + + // theagenticguy PR-241 review: the regex is g-flagged at module + // scope, which means `RegExp.prototype.exec` carries `lastIndex` + // across calls. The implementation explicitly resets it; this test + // pins the behavior so nobody removes the reset thinking it's dead. + test('back-to-back calls do not skip due to leftover g-flag lastIndex', () => { + // Run the same call twice — without the explicit reset, the second + // call would start scanning from where the first call left off and + // miss the leading identifier. + expect(extractLinearIdentifier('ABCA-1 then ABCA-2')).toBe('ABCA-1'); + expect(extractLinearIdentifier('ABCA-1 then ABCA-2')).toBe('ABCA-1'); + expect(extractLinearIdentifier('ABCA-1 then ABCA-2')).toBe('ABCA-1'); + }); + + test('back-to-back calls with different inputs each return their own first match', () => { + expect(extractLinearIdentifier('first ABCA-1')).toBe('ABCA-1'); + expect(extractLinearIdentifier('second PLAT-9')).toBe('PLAT-9'); + expect(extractLinearIdentifier('third PLAT-9 ABCA-1')).toBe('PLAT-9'); + expect(extractLinearIdentifier(null)).toBeNull(); + expect(extractLinearIdentifier('fourth ABCA-1')).toBe('ABCA-1'); + }); +}); diff --git a/cdk/test/handlers/shared/screenshot-url.test.ts b/cdk/test/handlers/shared/screenshot-url.test.ts new file mode 100644 index 00000000..1a911b28 --- /dev/null +++ b/cdk/test/handlers/shared/screenshot-url.test.ts @@ -0,0 +1,153 @@ +/** + * 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 { buildScreenshotKey, encodeMarkdownUrl, isAllowedScreenshotUrl } from '../../../src/handlers/shared/screenshot-url'; + +describe('buildScreenshotKey', () => { + test('produces a screenshots/_/--.png shape', () => { + const key = buildScreenshotKey('owner/repo', 'abc1234', 42); + expect(key).toMatch(/^screenshots\/owner_repo\/abc1234-42-[0-9a-f]{16}\.png$/); + }); + + test('omits deployment id segment when not provided', () => { + const key = buildScreenshotKey('owner/repo', 'abc1234'); + // No `-` between sha and the random suffix. + expect(key).toMatch(/^screenshots\/owner_repo\/abc1234-[0-9a-f]{16}\.png$/); + expect(key).not.toMatch(/abc1234-\d+-[0-9a-f]{16}/); + }); + + test('replaces ALL slashes in the repo slug, not just the first', () => { + // Defensive: GitHub repo names are owner/name (one slash), but + // `replace('/', '_')` would silently leave a second slash through. + // (theagenticguy PR-241 review nit.) + const key = buildScreenshotKey('owner/sub/repo', 'abc1234'); + expect(key.split('/')[0]).toBe('screenshots'); + expect(key.split('/')[1]).toBe('owner_sub_repo'); + }); + + test('high-entropy suffix differs across calls (URL is not enumerable)', () => { + // theagenticguy PR-241 review: keys without a random suffix are + // guessable from the public PR (org+repo+sha all visible). + const seen = new Set(); + for (let i = 0; i < 100; i++) { + seen.add(buildScreenshotKey('owner/repo', 'abc1234')); + } + expect(seen.size).toBe(100); + }); + + test('suffix is 16 hex chars (64 bits of entropy)', () => { + const key = buildScreenshotKey('owner/repo', 'abc1234'); + const suffix = key.match(/-([0-9a-f]+)\.png$/)?.[1]; + expect(suffix).toHaveLength(16); + }); +}); + +describe('isAllowedScreenshotUrl', () => { + test.each([ + ['https://preview.vercel.app', true], + ['https://abc-123.amplifyapp.com', true], + ['https://deploy-preview-12.netlify.app', true], + ['https://isadeks.github.io/repo/', true], + ['https://example.com:8443/path', true], + ])('accepts public https hostname %s', (url, expected) => { + expect(isAllowedScreenshotUrl(url)).toBe(expected); + }); + + test.each([ + ['http://example.com', 'http scheme'], + ['file:///etc/passwd', 'file scheme'], + ['data:text/html,

x

', 'data scheme'], + ['javascript:alert(1)', 'javascript scheme'], + ['ftp://ftp.example.com', 'ftp scheme'], + ])('rejects %s (%s)', (url) => { + expect(isAllowedScreenshotUrl(url)).toBe(false); + }); + + test('rejects malformed URLs', () => { + expect(isAllowedScreenshotUrl('not a url')).toBe(false); + expect(isAllowedScreenshotUrl('')).toBe(false); + }); + + test.each([ + ['https://localhost', 'localhost'], + ['https://localhost:3000', 'localhost with port'], + ['https://app.localhost', 'subdomain of localhost'], + ])('rejects %s (%s)', (url) => { + expect(isAllowedScreenshotUrl(url)).toBe(false); + }); + + test.each([ + ['https://127.0.0.1', 'IPv4 loopback'], + ['https://10.0.0.1', 'RFC1918 10/8'], + ['https://192.168.1.1', 'RFC1918 192.168/16'], + ['https://172.16.0.1', 'RFC1918 172.16/12'], + ['https://169.254.169.254', 'IMDS / link-local'], + ['https://1.2.3.4', 'arbitrary IPv4 literal'], + ])('rejects literal IPv4 %s (%s)', (url) => { + expect(isAllowedScreenshotUrl(url)).toBe(false); + }); + + test.each([ + ['https://[::1]/', 'IPv6 loopback ::1'], + ['https://[fe80::1]/', 'IPv6 link-local fe80::/10'], + ['https://[fc00::1]/', 'IPv6 unique-local fc00::/7'], + ['https://[fd12:3456:789a::1]/', 'IPv6 unique-local fd00::/8'], + ['https://[64:ff9b::1.2.3.4]/', 'NAT64 well-known prefix'], + ['https://[::ffff:10.0.0.1]/', 'IPv4-mapped IPv6'], + ['https://[2001:db8::1]:8443/path', 'global IPv6 with port + path'], + ])('rejects every IPv6 literal %s (%s)', (url) => { + // krokoko PR-241 round-3 finding 2: enumerating ranges missed + // fc00::/7 and NAT64. Preview URLs are always DNS names, so any + // IPv6 literal is rejected wholesale. + expect(isAllowedScreenshotUrl(url)).toBe(false); + }); + + test.each([ + ['https://2130706433', 'decimal integer form of 127.0.0.1'], + ['https://0x7f000001', 'hex integer form of 127.0.0.1'], + ])('rejects integer-encoded IPv4 %s (%s) — WHATWG normalizes to dotted-quad', (url) => { + expect(isAllowedScreenshotUrl(url)).toBe(false); + }); +}); + +describe('encodeMarkdownUrl', () => { + test('percent-encodes parens so a crafted path cannot break out of a markdown link', () => { + // krokoko PR-241 round-3 finding 1: the WHATWG URL parser keeps `)` + // in the path, so a clean-hostname URL can still close the `](…)` + // early and inject content into a comment posted under ABCA's token. + const attack = 'https://preview.vercel.app/x)](https://evil/a.png)'; + const encoded = encodeMarkdownUrl(attack); + expect(encoded).not.toContain('('); + expect(encoded).not.toContain(')'); + // No `](` delimiter survives → cannot break out of `[text](url)`. + expect(encoded).not.toContain(']('); + // Interpolated into the link, the body stays a single link. + const body = `[![preview](https://cdn/x.png)](${encoded})`; + expect(body.match(/\]\(/g)).toHaveLength(2); // image + link, nothing extra + }); + + test('leaves a normal preview URL functionally unchanged (browser decodes %28/%29)', () => { + const url = 'https://deploy-preview-12.netlify.app/path?x=1'; + expect(encodeMarkdownUrl(url)).toBe(url); + }); + + test('encodes every paren, not just the first', () => { + expect(encodeMarkdownUrl('https://h/a(b)c(d)')).toBe('https://h/a%28b%29c%28d%29'); + }); +}); diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index bec1ef15..859fb630 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -36,13 +36,14 @@ describe('AgentStack', () => { expect(template).toBeDefined(); }); - test('creates exactly 13 DynamoDB tables', () => { + test('creates exactly 14 DynamoDB tables', () => { // task, task-events, repo, user-concurrency, webhook, task-nudges, // task-approvals (Cedar HITL V2), // slack-installation, slack-user-mapping, // linear-project-mapping, linear-user-mapping, linear-webhook-dedup, - // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping) - template.resourceCountIs('AWS::DynamoDB::Table', 13); + // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping), + // github-webhook-dedup (added by GitHubScreenshotIntegration) + template.resourceCountIs('AWS::DynamoDB::Table', 14); }); test('creates TaskApprovalsTable with user_id-status-index GSI', () => { diff --git a/cli/src/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..1c25caf6 --- /dev/null +++ b/cli/src/commands/github.ts @@ -0,0 +1,230 @@ +/** + * 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 preview-deploy 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 (preview-deploy 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: 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); + }), + ); + + 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 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.'); + }), + ); + + 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); + }); +} diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index fe912344..e44cdfd3 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/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/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/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md new file mode 100644 index 00000000..0d81e58b --- /dev/null +++ b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md @@ -0,0 +1,207 @@ +# Deploy preview screenshots setup guide + +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. + +> 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` + +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 `, 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). | + +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 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. **(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. + +## How it works + +``` +agent push → provider preview build → deployment_status webhook + ↓ + POST /v1/github/webhook + ↓ + receiver Lambda (HMAC verify, dedup, + state=success + + environment filter) + ↓ + processor Lambda + ↓ + AgentCore Browser session + ↓ + PNG → private S3 (30-day TTL) + ↓ + CloudFront-served public URL + ↓ + 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 image embeds (and Linear's, when configured) can render them without auth. +- **WAF exemption.** The `/v1/github/webhook` path is exempted from the `SizeRestrictions_BODY` rule in `AWSManagedRulesCommonRuleSet` because the full `deployment_status` payload (workflow run history + deploy URLs + deployment metadata) exceeds the 8 KB body-size limit. All other CRS rules (LFI, RFI, XSS, SQLi, …) still evaluate against the path; HMAC verification in Lambda authenticates the body. + +## Prerequisites + +- ABCA stack deployed (`mise //cdk:deploy`) — confirm `GitHubWebhookUrl` + `GitHubWebhookSecretArn` + `ScreenshotCloudFrontDomain` are listed in the stack outputs +- (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 +- The `bgagent` CLI installed (`bgagent configure`, `bgagent login`) + +## 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. 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.** 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. + +### Step 3 — Configure the GitHub webhook + +This wires deploys back to ABCA's screenshot pipeline. + +#### 3a. Get the webhook config + +```bash +bgagent github webhook-info +``` + +The CLI prints the webhook URL and the values to paste into GitHub. + +#### 3b. Add the webhook on the GitHub repo + +1. Open `https://github.com///settings/hooks`. +2. Click **Add webhook**. +3. Fill in the values printed by `webhook-info`: + - **Payload URL**: the URL it printed + - **Content type**: `application/json` + - **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. + +#### 3c. Mirror the signing secret into AWS + +```bash +bgagent github set-webhook-secret +``` + +Paste the same secret you generated in 3b. The CLI writes it to the stack's `GitHubWebhookSecret` Secrets Manager entry, where the receiver Lambda reads it for HMAC verification. + +### Step 4 — Smoke test + +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 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 + +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 across all PRs (single fixed-string filter only) | +| 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 + +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` — 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 + +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. + +### CloudFront serves a 403 + +Visit the public URL directly: + +``` +https:///screenshots/_/--<16hex>.png +``` + +(Copy the exact URL from the PR comment — the `<16hex>` suffix is random per capture, so you can't hand-construct it.) + +If it 403s, check that the bucket policy includes the OAC service principal (CDK should generate this automatically — re-deploy if it doesn't). + +### 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. + +## Production hardening considerations + +Things to think about before using this on a real product: + +- **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 is scoped to the three AgentCore Browser actions the handler calls — `StartBrowserSession`, `StopBrowserSession`, `ConnectBrowserAutomationStream` — plus standard Lambda + S3 + Secrets Manager grants. The first two are control-plane writes; the third is the data-plane SigV4-presigned WSS handshake (it's published in the [AWS Service Authorization Reference for `bedrock-agentcore`](https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonbedrockagentcore.html), which also notes it takes no resource types or condition keys). Resource is `*` because Browser sessions are ephemeral and the data-plane stream actions don't support resource-level scoping. A cdk-nag IAM5 suppression annotates the resource wildcard. +- **SSRF surface.** The processor navigates AgentCore Browser to `deployment_status.environment_url` from the verified webhook payload. The handler validates the URL up front (https only, no literal-IP, no localhost / link-local / loopback) so a forged payload can't pivot the browser at private hosts. AgentCore Browser also runs outside the customer VPC, so IMDS and private-subnet pivots are neutralized regardless. Stricter operators can add an explicit hostname allowlist by editing `isAllowedScreenshotUrl` in `cdk/src/handlers/shared/screenshot-url.ts`. +- **Screenshot URL enumerability.** The bucket is private, but CloudFront serves anonymously and the path follows `screenshots/_/-<8-byte-random>.png`. The 64-bit random suffix makes URLs unguessable for an outside reader (the prefix is enumerable from the public PR; the suffix is not). If your previews regularly render PII or other regulated content, consider also enabling CloudFront access logs + a WAF in front of the CDN and shortening screenshot retention below the 30-day default (constant in `cdk/src/constructs/screenshot-bucket.ts`). 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/guides/ROADMAP.md b/docs/guides/ROADMAP.md index 1be7721e..2103180c 100644 --- a/docs/guides/ROADMAP.md +++ b/docs/guides/ROADMAP.md @@ -78,6 +78,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 40f29cd9..8b870f20 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -1024,6 +1024,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/scripts/sync-starlight.mjs b/docs/scripts/sync-starlight.mjs index 0db28865..1a1a1be1 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', + DEPLOY_PREVIEW_SCREENSHOTS_GUIDE: '/using/deploy-preview-screenshots-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'), ); +// --- Deploy preview screenshots guide: mirror to using/ --- +mirrorMarkdownFile( + 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) --- mirrorMarkdownFile( path.join(docsRoot, 'guides', 'CEDAR_POLICY_GUIDE.md'), 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 583b66a3..8fcaf97c 100644 --- a/docs/src/content/docs/roadmap/Roadmap.md +++ b/docs/src/content/docs/roadmap/Roadmap.md @@ -82,6 +82,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/Deploy-preview-screenshots-guide.md b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md new file mode 100644 index 00000000..b209f3b3 --- /dev/null +++ b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md @@ -0,0 +1,211 @@ +--- +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 the open GitHub PR. If you also have Linear configured, the same screenshot is posted to the linked Linear issue as a bonus. + +> 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` + +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 `, 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). | + +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 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. **(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. + +## How it works + +``` +agent push → provider preview build → deployment_status webhook + ↓ + POST /v1/github/webhook + ↓ + receiver Lambda (HMAC verify, dedup, + state=success + + environment filter) + ↓ + processor Lambda + ↓ + AgentCore Browser session + ↓ + PNG → private S3 (30-day TTL) + ↓ + CloudFront-served public URL + ↓ + 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 image embeds (and Linear's, when configured) can render them without auth. +- **WAF exemption.** The `/v1/github/webhook` path is exempted from the `SizeRestrictions_BODY` rule in `AWSManagedRulesCommonRuleSet` because the full `deployment_status` payload (workflow run history + deploy URLs + deployment metadata) exceeds the 8 KB body-size limit. All other CRS rules (LFI, RFI, XSS, SQLi, …) still evaluate against the path; HMAC verification in Lambda authenticates the body. + +## Prerequisites + +- ABCA stack deployed (`mise //cdk:deploy`) — confirm `GitHubWebhookUrl` + `GitHubWebhookSecretArn` + `ScreenshotCloudFrontDomain` are listed in the stack outputs +- (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 +- The `bgagent` CLI installed (`bgagent configure`, `bgagent login`) + +## 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. 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.** 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. + +### Step 3 — Configure the GitHub webhook + +This wires deploys back to ABCA's screenshot pipeline. + +#### 3a. Get the webhook config + +```bash +bgagent github webhook-info +``` + +The CLI prints the webhook URL and the values to paste into GitHub. + +#### 3b. Add the webhook on the GitHub repo + +1. Open `https://github.com///settings/hooks`. +2. Click **Add webhook**. +3. Fill in the values printed by `webhook-info`: + - **Payload URL**: the URL it printed + - **Content type**: `application/json` + - **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. + +#### 3c. Mirror the signing secret into AWS + +```bash +bgagent github set-webhook-secret +``` + +Paste the same secret you generated in 3b. The CLI writes it to the stack's `GitHubWebhookSecret` Secrets Manager entry, where the receiver Lambda reads it for HMAC verification. + +### Step 4 — Smoke test + +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 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 + +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 across all PRs (single fixed-string filter only) | +| 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 + +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` — 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 + +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. + +### CloudFront serves a 403 + +Visit the public URL directly: + +``` +https:///screenshots/_/--<16hex>.png +``` + +(Copy the exact URL from the PR comment — the `<16hex>` suffix is random per capture, so you can't hand-construct it.) + +If it 403s, check that the bucket policy includes the OAC service principal (CDK should generate this automatically — re-deploy if it doesn't). + +### 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. + +## Production hardening considerations + +Things to think about before using this on a real product: + +- **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 is scoped to the three AgentCore Browser actions the handler calls — `StartBrowserSession`, `StopBrowserSession`, `ConnectBrowserAutomationStream` — plus standard Lambda + S3 + Secrets Manager grants. The first two are control-plane writes; the third is the data-plane SigV4-presigned WSS handshake (it's published in the [AWS Service Authorization Reference for `bedrock-agentcore`](https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonbedrockagentcore.html), which also notes it takes no resource types or condition keys). Resource is `*` because Browser sessions are ephemeral and the data-plane stream actions don't support resource-level scoping. A cdk-nag IAM5 suppression annotates the resource wildcard. +- **SSRF surface.** The processor navigates AgentCore Browser to `deployment_status.environment_url` from the verified webhook payload. The handler validates the URL up front (https only, no literal-IP, no localhost / link-local / loopback) so a forged payload can't pivot the browser at private hosts. AgentCore Browser also runs outside the customer VPC, so IMDS and private-subnet pivots are neutralized regardless. Stricter operators can add an explicit hostname allowlist by editing `isAllowedScreenshotUrl` in `cdk/src/handlers/shared/screenshot-url.ts`. +- **Screenshot URL enumerability.** The bucket is private, but CloudFront serves anonymously and the path follows `screenshots/_/-<8-byte-random>.png`. The 64-bit random suffix makes URLs unguessable for an outside reader (the prefix is enumerable from the public PR; the suffix is not). If your previews regularly render PII or other regulated content, consider also enabling CloudFront access logs + a WAF in front of the CDN and shortening screenshot retention below the 30-day default (constant in `cdk/src/constructs/screenshot-bucket.ts`). 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 diff --git a/docs/src/content/docs/using/Task-lifecycle.md b/docs/src/content/docs/using/Task-lifecycle.md index 9fb98b89..fcb52f83 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 (`coding/pr-iteration-v1` or `coding/pr-revie 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 diff --git a/yarn.lock b/yarn.lock index 41612676..dffa42af 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5629,6 +5629,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" @@ -11205,6 +11212,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"