diff --git a/cdk/package.json b/cdk/package.json index 1bf5d916..8310beda 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.10.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.14", "aws-cdk-lib": "^2.257.0", "cdk-nag": "^2.38.2", "constructs": "^10.3.0", - "pdf-parse": "^1.1.1", "js-yaml": "^4.1.1", - "ulid": "^3.0.2" + "pdf-parse": "^1.1.1", + "ulid": "^3.0.2", + "ws": "^8.18.0" }, "devDependencies": { "@cdklabs/eslint-plugin": "^2", @@ -44,6 +49,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/pdf-parse": "^1.1.4", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "aws-cdk": "^2", diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts new file mode 100644 index 00000000..6c8fe55d --- /dev/null +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -0,0 +1,286 @@ +/** + * 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) + * - Public-read screenshot S3 bucket + * - API Gateway route `POST /v1/github/webhook` + * + * Inbound-only adapter — there's no outbound polling or stream + * consumer, just the webhook → screenshot → comment fan-out. + */ +export class GitHubScreenshotIntegration extends Construct { + /** Public-read bucket hosting the screenshot PNGs. */ + public readonly screenshotBucket: ScreenshotBucket; + + /** + * GitHub webhook signing secret — placeholder. The operator pastes + * GitHub's signing-secret value here after configuring the webhook + * in the demo repo's settings; the secret is otherwise empty. + */ + public readonly webhookSecret: secretsmanager.Secret; + + /** Webhook dedup table (composite key = `repo#deployment_id#status_id`). */ + public readonly webhookDedupTable: dynamodb.Table; + + /** Webhook receiver Lambda (HMAC verifier + dispatcher). */ + public readonly webhookFn: lambda.NodejsFunction; + + /** Async processor Lambda (browser + S3 + PR comment). */ + public readonly webhookProcessorFn: lambda.NodejsFunction; + + constructor(scope: Construct, id: string, props: GitHubScreenshotIntegrationProps) { + super(scope, id); + + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; + + // --- Screenshot bucket (public-read on `screenshots/*`) --- + this.screenshotBucket = new ScreenshotBucket(this, 'ScreenshotBucket', { + removalPolicy, + }); + + // --- Webhook signing secret (operator-populated placeholder) --- + this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', { + description: 'GitHub deployment-status webhook signing secret — populate manually after configuring the GitHub webhook', + removalPolicy, + }); + + // --- Dedup table --- + this.webhookDedupTable = new dynamodb.Table(this, 'WebhookDedupTable', { + partitionKey: { name: 'dedup_key', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true }, + removalPolicy, + }); + + const handlersDir = path.join(__dirname, '..', 'handlers'); + const commonBundling: lambda.BundlingOptions = { + externalModules: ['@aws-sdk/*'], + }; + + // --- Async processor (browser + S3 + comment) --- + // Timeout budget: 60s screenshot + 5s navigate slack + 30s slack for + // the GitHub PR-lookup + comment + S3 PUT + JSON encode = 95s. Round + // to 120 for headroom on cold-start CDP handshake. + this.webhookProcessorFn = new lambda.NodejsFunction(this, 'WebhookProcessorFn', { + entry: path.join(handlersDir, 'github-webhook-processor.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(120), + memorySize: 512, + environment: { + SCREENSHOT_BUCKET_NAME: this.screenshotBucket.bucket.bucketName, + SCREENSHOT_PUBLIC_HOST: this.screenshotBucket.distribution.domainName, + GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, + ...(props.linearWorkspaceRegistryTable && { + LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: props.linearWorkspaceRegistryTable.tableName, + }), + }, + bundling: commonBundling, + }); + + this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn); + props.githubTokenSecret.grantRead(this.webhookProcessorFn); + + // Optional Linear feedback path. Wired only when a registry table + // is provided. The processor scans the registry for active + // workspaces, then per-workspace looks up the OAuth token from + // Secrets Manager (`bgagent-linear-oauth-*` prefix, written by + // `bgagent linear setup`). + if (props.linearWorkspaceRegistryTable) { + props.linearWorkspaceRegistryTable.grantReadData(this.webhookProcessorFn); + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-linear-oauth-*', + }), + ], + })); + } + + // AgentCore Browser session lifecycle + automation-stream connect. + // The data-plane API doesn't support per-resource ARNs (sessions + // are ephemeral), so the resource wildcard is required — annotated + // with a cdk-nag suppression below. + // + // Actions are scoped to the three calls the handler actually makes: + // - StartBrowserSession + StopBrowserSession (REST control plane, + // in the public CLI command list) + // - ConnectBrowserAutomationStream (the SigV4-presigned WSS dial; + // not in the public CLI command list, but verified live against + // the deployed dev stack — IAM accepts the action name even + // though aws cli help doesn't surface it) + // + // Previously this used `bedrock-agentcore:*` which granted the + // entire AgentCore action surface (memory, runtime, gateway, + // identity, code-interpreter). Per krokoko's PR #241 review item + // #1: scope down to least privilege. If a future API change adds a + // call we need, IAM will deny with the specific action name in + // CloudTrail and we can add it explicitly. + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore: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..76c4b6b7 --- /dev/null +++ b/cdk/src/constructs/screenshot-bucket.ts @@ -0,0 +1,152 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Duration, RemovalPolicy } from 'aws-cdk-lib'; +import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; +import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; + +/** Lifecycle expiry for screenshot artifacts. */ +export const SCREENSHOT_TTL_DAYS = 30; + +/** + * Object-key prefix for all screenshots. Key layout: + * ``screenshots//.png``. The CloudFront distribution serves + * the entire bucket, but the processor only ever writes under this + * prefix. + */ +export const SCREENSHOT_KEY_PREFIX = 'screenshots/'; + +/** + * Properties for ScreenshotBucket construct. + */ +export interface ScreenshotBucketProps { + /** + * Removal policy for the bucket + distribution. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to auto-delete objects when the bucket is removed. + * @default true + */ + readonly autoDeleteObjects?: boolean; +} + +/** + * Private S3 bucket fronted by a CloudFront distribution that serves + * screenshot PNGs to GitHub Markdown / Linear render pipelines. + * + * Why CloudFront and not a public-read bucket: the AWS account-level + * Block Public Access is on (S3 control plane refuses to attach any + * public bucket policy), and disabling it would change the security + * posture of the whole account. CloudFront with Origin Access Control + * is the AWS-recommended path for "S3 object served anonymously over + * HTTPS." Bucket stays fully private; only the distribution principal + * has GetObject. + * + * Layout: + * s3:///screenshots//.png (private) + * https://.cloudfront.net/screenshots//.png (anon) + * + * The 30-day lifecycle on the bucket is the source of truth for + * expiry — CloudFront's edge caches will see 403s after the TTL + * lapses, which is fine for stale PR comments. + */ +export class ScreenshotBucket extends Construct { + /** The underlying private S3 bucket. */ + public readonly bucket: s3.Bucket; + + /** CloudFront distribution serving the bucket anonymously. */ + public readonly distribution: cloudfront.Distribution; + + constructor(scope: Construct, id: string, props: ScreenshotBucketProps = {}) { + super(scope, id); + + this.bucket = new s3.Bucket(this, 'Bucket', { + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + lifecycleRules: [ + { + id: 'screenshot-ttl', + enabled: true, + expiration: Duration.days(SCREENSHOT_TTL_DAYS), + abortIncompleteMultipartUploadAfter: Duration.days(1), + }, + ], + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + autoDeleteObjects: props.autoDeleteObjects ?? true, + }); + + // CloudFront → S3 via Origin Access Control. The bucket policy is + // generated automatically by `S3BucketOrigin.withOriginAccessControl` + // and grants `s3:GetObject` to the distribution's CF service principal + // only — no anonymous principal in the policy, so account-level BPA + // doesn't reject it. + this.distribution = new cloudfront.Distribution(this, 'Distribution', { + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + // Screenshots are immutable per (repo, sha) — long TTL is safe + // and minimizes origin S3 requests on hot PRs. + cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, + allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD, + }, + // No alternate domain or ACM cert — the default + // *.cloudfront.net hostname is fine for a backend artifact host. + enableLogging: false, + comment: 'ABCA screenshot artifacts (private S3 + OAC)', + }); + + NagSuppressions.addResourceSuppressions(this.bucket, [ + { + id: 'AwsSolutions-S1', + reason: + 'Server access logs are not enabled for this bucket; screenshots are ephemeral artifacts (30-day TTL) embedded in GitHub PR comments. Adding access logging would generate substantial log volume for a low-value security signal.', + }, + ], true); + + NagSuppressions.addResourceSuppressions(this.distribution, [ + { + id: 'AwsSolutions-CFR1', + reason: 'No geo restrictions are needed — screenshots are referenced from GitHub.com which is global; restricting origins would break cross-region PR reviewers.', + }, + { + id: 'AwsSolutions-CFR2', + reason: 'AWS WAF is not attached to this distribution. The content is read-only PNGs of preview deploys; no app logic, no input handling, no auth — WAF would only add cost without reducing risk.', + }, + { + id: 'AwsSolutions-CFR3', + reason: 'Access logs are not enabled on the distribution for the same reason as the bucket — low-value high-volume signal for ephemeral artifacts.', + }, + { + id: 'AwsSolutions-CFR4', + reason: 'Distribution uses the default *.cloudfront.net certificate (TLSv1+ enforced by AWS). No custom domain, so no minimum-TLS-version override needed.', + }, + { + id: 'AwsSolutions-CFR7', + reason: 'OAC is in use (the construct calls `S3BucketOrigin.withOriginAccessControl`). cdk-nag misclassifies the L2 helper as an OAI deployment.', + }, + ], true); + } +} diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 6ecd745a..cf6ec312 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -295,6 +295,17 @@ export class TaskApi extends Construct { textTransformations: [{ priority: 0, type: 'NONE' }], }, }, + { + // GitHub deployment_status webhook (Vercel preview + // screenshot pipeline) — absolute deploy URLs trip + // GenericRFI_BODY. HMAC-verified in Lambda. + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/github/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, ], }, }, @@ -342,6 +353,18 @@ export class TaskApi extends Construct { }, }, }, + { + notStatement: { + statement: { + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/github/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, + }, + }, ], }, }, diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts new file mode 100644 index 00000000..8c7ce378 --- /dev/null +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -0,0 +1,378 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { captureScreenshot } from './shared/agentcore-browser'; +import { resolveGitHubToken } from './shared/context-hydration'; +import { upsertTaskComment } from './shared/github-comment'; +import { postIssueComment } from './shared/linear-feedback'; +import { extractLinearIdentifier, findLinearIssueByIdentifier } from './shared/linear-issue-lookup'; +import { logger } from './shared/logger'; + +const s3 = new S3Client({}); + +const SCREENSHOT_BUCKET = process.env.SCREENSHOT_BUCKET_NAME!; +// CloudFront distribution domain — `.cloudfront.net`. Used as +// the public host for the screenshot URL embedded in PR comments. +// The bucket is private; CloudFront with OAC reads on the agent's +// behalf. +const SCREENSHOT_PUBLIC_HOST = process.env.SCREENSHOT_PUBLIC_HOST!; +const GITHUB_TOKEN_SECRET_ARN = process.env.GITHUB_TOKEN_SECRET_ARN!; +// Optional — when set, the processor also tries to post the +// screenshot comment onto a linked Linear issue. Resolved from the +// GitHub PR title/body via a Linear-identifier regex (e.g. `ABCA-42`), +// then looked up across all active workspaces in the registry. +const LINEAR_WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; + +interface GitHubDeploymentStatusPayload { + readonly action?: string; + readonly deployment_status?: { + readonly id?: number; + readonly state?: string; + readonly target_url?: string; + /** The deployed URL — lives on the *status* object, not the deployment. */ + readonly environment_url?: string; + }; + readonly deployment?: { + readonly id?: number; + readonly sha?: string; + readonly environment?: string; + }; + readonly repository?: { + readonly full_name?: string; + }; +} + +interface ProcessorEvent { + readonly raw_body: string; +} + +/** + * Async processor for verified GitHub `deployment_status` webhooks. + * + * Flow: + * 1. Parse the payload (already validated as deployment_status by the + * receiver, but we re-extract the fields we need). + * 2. Find the open PR for the deploy SHA via the GitHub Commits API. + * 3. Capture a screenshot of `deployment.environment_url` via + * AgentCore Browser. + * 4. PUT the PNG to the screenshot bucket. + * 5. POST a fresh PR comment with `![preview]()`. + * + * Every external call is best-effort. If any step fails, log + return — + * the receiver already 200'd, so retries by GitHub will dedup at the + * receiver layer. + */ +export async function handler(event: ProcessorEvent): Promise { + if (!event.raw_body) { + logger.error('GitHub webhook processor invoked without raw_body'); + return; + } + + let payload: GitHubDeploymentStatusPayload; + try { + payload = JSON.parse(event.raw_body) as GitHubDeploymentStatusPayload; + } catch (err) { + logger.error('GitHub webhook processor could not parse raw_body', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const repo = payload.repository?.full_name; + const sha = payload.deployment?.sha; + // The URL lives on `deployment_status` (it changes per status update — + // `pending` has no URL, `success` fills it in), not on `deployment`. + const previewUrl = payload.deployment_status?.environment_url; + const deploymentId = payload.deployment?.id; + + if (!repo || !sha || !previewUrl) { + logger.warn('GitHub deployment_status payload missing required fields', { + repo, + sha_present: Boolean(sha), + preview_url_present: Boolean(previewUrl), + deployment_id: deploymentId, + }); + return; + } + + logger.info('Screenshot pipeline starting', { + repo, + sha, + preview_url: previewUrl, + deployment_id: deploymentId, + }); + + let token: string; + try { + token = await resolveGitHubToken(GITHUB_TOKEN_SECRET_ARN); + } catch (err) { + logger.error('Failed to resolve GitHub token; cannot post screenshot comment', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + // Race: managed providers (Vercel, Netlify, Amplify) post + // `deployment_status` the moment their build finishes, which can + // be ~5-15s before the agent calls `gh pr create` for the same SHA. + // Retry the PR lookup with a small backoff so the screenshot doesn't + // get silently dropped on what is the common path. + const pr = await findPullRequestForShaWithRetry(repo, sha, token); + if (!pr) { + logger.info('No open PR found for SHA after retries — skipping screenshot post', { repo, sha }); + return; + } + + let png: Uint8Array; + try { + png = await captureScreenshot(previewUrl); + } catch (err) { + logger.error('Screenshot capture failed', { + preview_url: previewUrl, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const key = buildScreenshotKey(repo, sha, deploymentId); + try { + await s3.send(new PutObjectCommand({ + Bucket: SCREENSHOT_BUCKET, + Key: key, + Body: png, + ContentType: 'image/png', + Metadata: { + repo, + sha, + // S3 metadata values must be ASCII; coerce numeric to string and + // skip the URL itself (URL encoding into x-amz-meta-* is brittle). + deployment_id: String(deploymentId ?? ''), + }, + })); + } catch (err) { + logger.error('Failed to upload screenshot to S3', { + bucket: SCREENSHOT_BUCKET, + key, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const publicUrl = `https://${SCREENSHOT_PUBLIC_HOST}/${key}`; + const commentBody = renderCommentBody(publicUrl, previewUrl); + + try { + const result = await upsertTaskComment({ + repo, + issueOrPrNumber: pr.number, + body: commentBody, + token, + // Always POST fresh — a single PR can have multiple preview screenshots + // as the user pushes new commits, and editing the prior comment in + // place would lose the history. + existingCommentId: undefined, + }); + logger.info('Posted screenshot comment to PR', { + repo, + pr_number: pr.number, + comment_id: result.commentId, + public_url: publicUrl, + }); + } catch (err) { + logger.warn('Failed to post screenshot PR comment (non-fatal)', { + repo, + pr_number: pr.number, + error: err instanceof Error ? err.message : String(err), + }); + } + + // Best-effort Linear comment. The GitHub PR comment above is the + // load-bearing artifact; the Linear comment is bonus surface for + // reviewers who live in Linear. Only fires when the registry table + // is configured AND the PR title/body carries a Linear identifier. + if (LINEAR_WORKSPACE_REGISTRY_TABLE) { + const identifier = extractLinearIdentifier(pr.title) ?? extractLinearIdentifier(pr.body); + if (identifier) { + const linearIssue = await findLinearIssueByIdentifier(identifier, LINEAR_WORKSPACE_REGISTRY_TABLE); + if (linearIssue) { + const ok = await postIssueComment( + { + linearWorkspaceId: linearIssue.linearWorkspaceId, + registryTableName: LINEAR_WORKSPACE_REGISTRY_TABLE, + }, + linearIssue.issueId, + renderLinearCommentBody(publicUrl, previewUrl), + ); + if (ok) { + logger.info('Posted screenshot comment to Linear issue', { + identifier, + linear_issue_id: linearIssue.issueId, + workspace_slug: linearIssue.workspaceSlug, + }); + } else { + logger.warn('Failed to post screenshot Linear comment (non-fatal)', { + identifier, + linear_issue_id: linearIssue.issueId, + }); + } + } else { + logger.info('Linear identifier did not resolve to an issue — skipping Linear post', { + identifier, + repo, + pr_number: pr.number, + }); + } + } + } +} + +/** + * Open PR shape we extract from the GitHub commit-pulls API. Title + + * body are used downstream by the Linear issue lookup; the others go + * into log lines for debugging. + */ +interface OpenPr { + readonly number: number; + readonly title: string; + readonly body: string; +} + +/** + * Wait for an open PR to exist for the given SHA, retrying with a + * small backoff. Managed providers commonly post `deployment_status` + * before the agent's `gh pr create` call lands (we've measured 5-15s + * gap on Vercel; Netlify/Amplify behave similarly), so a single check + * would silently miss the common case. + * + * Schedule: 0s, 5s, 10s, 20s — covers the observed gap with one + * generous bonus retry. Total max wait ~35s. + */ +async function findPullRequestForShaWithRetry( + repo: string, + sha: string, + token: string, +): Promise { + const delays = [0, 5_000, 10_000, 20_000]; + for (const delay of delays) { + if (delay > 0) { + await new Promise((r) => setTimeout(r, delay)); + } + const pr = await findPullRequestForSha(repo, sha, token); + if (pr) return pr; + if (delay !== delays[delays.length - 1]) { + logger.info('Open PR not found yet for SHA — will retry', { repo, sha, next_delay_ms: delays[delays.indexOf(delay) + 1] }); + } + } + return null; +} + +/** + * Look up an open PR associated with `sha`. Uses the + * "List pull requests associated with a commit" GitHub API + * (https://docs.github.com/rest/commits/commits#list-pull-requests-associated-with-a-commit). + * + * Returns the first OPEN PR (with title/body), or null if none. + * Closed/merged PRs are filtered out — v1 only screenshots active + * reviews. + */ +async function findPullRequestForSha( + repo: string, + sha: string, + token: string, +): Promise { + const url = `https://api.github.com/repos/${repo}/commits/${sha}/pulls`; + let res: Response; + try { + res = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + } catch (err) { + logger.warn('GitHub commit-pulls fetch failed', { + repo, + sha, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (!res.ok) { + logger.warn('GitHub commit-pulls returned non-2xx', { + repo, + sha, + status: res.status, + }); + return null; + } + + const pulls = (await res.json()) as Array<{ + number?: number; + state?: string; + title?: string; + body?: string | null; + }>; + const open = pulls.find((p) => p.state === 'open' && typeof p.number === 'number'); + if (!open) return null; + return { + number: open.number!, + title: open.title ?? '', + body: open.body ?? '', + }; +} + +/** Build the S3 key for a screenshot. */ +function buildScreenshotKey(repo: string, sha: string, deploymentId: number | undefined): string { + const repoSlug = repo.replace('/', '_'); + const id = deploymentId !== undefined ? `-${deploymentId}` : ''; + return `screenshots/${repoSlug}/${sha}${id}.png`; +} + +/** Render the PR comment body. */ +function renderCommentBody(publicUrl: string, previewUrl: string): string { + return [ + '🖼️ **Preview screenshot**', + '', + `[![preview](${publicUrl})](${previewUrl})`, + '', + `_From [preview link](${previewUrl}) — captured automatically by ABCA after the deploy finished._`, + ].join('\n'); +} + +/** + * Linear comment body. Linear's markdown renders image embeds the + * same way GitHub does, but Linear collapses linked-image syntax — + * use the simpler `![alt](url)` form so it renders inline rather than + * as a clickable link with a tiny preview. + */ +function renderLinearCommentBody(publicUrl: string, previewUrl: string): string { + return [ + '🖼️ **Preview screenshot**', + '', + `![preview](${publicUrl})`, + '', + `[Preview link](${previewUrl})`, + '', + '_Captured automatically by ABCA after the deploy finished._', + ].join('\n'); +} diff --git a/cdk/src/handlers/github-webhook.ts b/cdk/src/handlers/github-webhook.ts new file mode 100644 index 00000000..02a73f3c --- /dev/null +++ b/cdk/src/handlers/github-webhook.ts @@ -0,0 +1,261 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { verifyGitHubRequest } from './shared/github-webhook-verify'; +import { logger } from './shared/logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const lambdaClient = new LambdaClient({}); + +const WEBHOOK_SECRET_ARN = process.env.GITHUB_WEBHOOK_SECRET_ARN!; +const DEDUP_TABLE_NAME = process.env.GITHUB_WEBHOOK_DEDUP_TABLE_NAME!; +const PROCESSOR_FUNCTION_NAME = process.env.GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NAME!; + +/** + * Dedup window. GitHub redelivers a webhook up to 5 times when our + * receiver returns 5xx (each retry ~ exponential backoff, max ~30s + * apart). 1h is generous coverage with slack for clock skew. + */ +const DEDUP_TTL_SECONDS = 60 * 60; + +/** + * Subset of GitHub's `deployment_status` payload we route on. Any deploy + * backend that calls the GitHub Deployments API posts this when a deploy + * finishes — Vercel, AWS Amplify Hosting, Netlify, Cloud Run, or your + * own GitHub Actions workflow that calls `POST /repos/.../deployments`. + * The interesting fields: + * - `deployment_status.state`: `success` | `failure` | `error` | `pending` | `in_progress` + * - `deployment_status.environment_url`: the deployed URL — lives on the + * *status* object, not the deployment itself. (The deployment object + * only has the immutable SHA + environment name; URL changes per + * status update — first `pending` has no URL, then `success` fills + * it in.) + * - `deployment.environment`: 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) + * + * Full payload is forwarded to the processor without re-serialization + * risk — the processor parses its own copy from the raw body. + */ +interface GitHubDeploymentStatusEnvelope { + readonly action?: string; + readonly deployment_status?: { + readonly id?: number; + readonly state?: string; + readonly environment_url?: string; + }; + readonly deployment?: { + readonly id?: number; + readonly sha?: string; + readonly environment?: string; + }; + readonly repository?: { + readonly full_name?: string; + }; +} + +/** + * POST /v1/github/webhook — GitHub webhook receiver. + * + * Verifies `X-Hub-Signature-256` (per + * https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries), + * filters to 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 payload: GitHubDeploymentStatusEnvelope; + try { + payload = JSON.parse(event.body) as GitHubDeploymentStatusEnvelope; + } catch (err) { + logger.warn('GitHub webhook body is not valid JSON', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(400, { error: 'Invalid JSON' }); + } + + // GitHub deployment_status events have multiple intermediate states + // (`pending`, `in_progress`) before the terminal `success` / + // `failure` / `error`. Only `success` deploys are worth + // screenshotting; everything else gets a clean 200 so GitHub + // doesn't retry. + if (payload.deployment_status?.state !== 'success') { + return jsonResponse(200, { ok: true, skipped_state: payload.deployment_status?.state }); + } + + // 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 (payload.deployment?.environment !== targetEnv) { + return jsonResponse(200, { + ok: true, + skipped_environment: payload.deployment?.environment, + }); + } + + const repo = payload.repository?.full_name; + const deploymentId = payload.deployment?.id; + const statusId = payload.deployment_status?.id; + if (!repo || !deploymentId || !statusId) { + logger.warn('GitHub deployment_status webhook missing repo, deployment id, or status id', { + repo, + deployment_id: deploymentId, + status_id: statusId, + }); + return jsonResponse(400, { error: 'Missing repo, deployment id, or status id' }); + } + + if (!payload.deployment_status?.environment_url) { + logger.warn('GitHub deployment_status webhook missing environment_url; cannot screenshot', { + repo, + deployment_id: deploymentId, + }); + return jsonResponse(200, { ok: true, skipped_no_url: true }); + } + + // Dedup on (repo, deployment_id, status_id). A single deploy lifecycle + // can emit multiple statuses; using the status id as the third leg + // keeps reruns of the same status (GitHub retries on 5xx) collapsed + // while distinct status transitions stay distinct. + const dedupKey = `${repo}#${deploymentId}#${statusId}`; + const nowSeconds = Math.floor(Date.now() / 1000); + try { + await ddb.send(new PutCommand({ + TableName: DEDUP_TABLE_NAME, + Item: { + dedup_key: dedupKey, + created_at: new Date().toISOString(), + ttl: nowSeconds + DEDUP_TTL_SECONDS, + }, + ConditionExpression: 'attribute_not_exists(dedup_key)', + })); + } catch (err) { + if (err instanceof ConditionalCheckFailedException) { + logger.info('GitHub webhook dedup hit — skipping reprocess', { + dedup_key: dedupKey, + }); + return jsonResponse(200, { ok: true, deduped: true }); + } + throw err; + } + + try { + await lambdaClient.send(new InvokeCommand({ + FunctionName: PROCESSOR_FUNCTION_NAME, + InvocationType: 'Event', + Payload: new TextEncoder().encode(JSON.stringify({ raw_body: event.body })), + })); + } catch (invokeErr) { + logger.error('Failed to invoke GitHub webhook processor', { + error: invokeErr instanceof Error ? invokeErr.message : String(invokeErr), + repo, + deployment_id: deploymentId, + status_id: statusId, + }); + // Roll the dedup row back so GitHub's retry can try dispatch again. + try { + await ddb.send(new DeleteCommand({ + TableName: DEDUP_TABLE_NAME, + Key: { dedup_key: dedupKey }, + })); + } catch (cleanupErr) { + logger.warn('Failed to roll back GitHub webhook dedup row after invoke failure', { + error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + dedup_key: dedupKey, + }); + } + return jsonResponse(500, { error: 'Dispatch failed' }); + } + + return jsonResponse(200, { ok: true }); + } catch (err) { + logger.error('GitHub webhook handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(500, { error: 'Internal server error' }); + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/linear-webhook-processor.ts b/cdk/src/handlers/linear-webhook-processor.ts index 18ad6a27..bf34a00e 100644 --- a/cdk/src/handlers/linear-webhook-processor.ts +++ b/cdk/src/handlers/linear-webhook-processor.ts @@ -31,7 +31,7 @@ const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); const PROJECT_MAPPING_TABLE = process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME!; const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!; const WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; -const DEFAULT_LABEL_FILTER = 'bgagent'; +const DEFAULT_LABEL_FILTER = 'abca'; /** * Post a Linear comment + ❌ reaction without ever propagating an error. @@ -150,6 +150,48 @@ export async function handler(event: ProcessorEvent): Promise { const issue = payload.data; const projectId = issue.projectId; + + // Resolve the per-project label override (if any) BEFORE the label gate so + // a workspace using a non-default label name still triggers correctly. The + // lookup runs on every Issue webhook (one extra GetItem vs. lookup-after- + // projectId-check), which is the price of having the silent label gate + // come first — see comment on the `shouldTrigger` block below. + let mappingItem: Record | undefined; + if (projectId) { + const mapping = await ddb.send(new GetCommand({ + TableName: PROJECT_MAPPING_TABLE, + Key: { linear_project_id: projectId }, + })); + if (mapping.Item && mapping.Item.status === 'active') { + mappingItem = mapping.Item; + } + } + const labelFilter = (mappingItem?.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER; + + // Silent kill-switch: an issue without the trigger label is not for us. + // This MUST run before any user-facing comment path. Previously the + // projectId-missing and not-onboarded paths ran first and posted + // "❌ project isn't onboarded" comments on every Issue event in every + // unmapped team — workspace webhooks fire workspace-wide, so a single + // un-onboarded team produced dozens of comments per issue change. + // Moving the label check first means an unlabeled issue is a true no-op: + // no comment, no reaction, no task creation, no DDB writes. + if (!shouldTrigger(payload, labelFilter)) { + logger.info('Linear webhook does not match trigger criteria — skipping silently', { + action: payload.action, + issue_id: issue.id, + label_filter: labelFilter, + has_project_mapping: Boolean(mappingItem), + current_labels: issue.labels?.map((l) => l?.name), + updated_from_keys: Object.keys(payload.updatedFrom ?? {}), + updated_from_label_ids: payload.updatedFrom?.labelIds, + current_label_ids: issue.labels?.map((l) => l?.id), + }); + return; + } + + // From here on the issue is labeled for ABCA, so user-facing failure + // comments are appropriate — the user explicitly asked for our attention. if (!projectId) { logger.info('Linear Issue has no projectId — skipping (cannot route to a repo)', { issue_id: issue.id, @@ -162,12 +204,7 @@ export async function handler(event: ProcessorEvent): Promise { return; } - // Look up project → repo mapping. - const mapping = await ddb.send(new GetCommand({ - TableName: PROJECT_MAPPING_TABLE, - Key: { linear_project_id: projectId }, - })); - if (!mapping.Item || mapping.Item.status !== 'active') { + if (!mappingItem) { logger.info('Linear project is not onboarded or is removed — skipping', { linear_project_id: projectId, issue_id: issue.id, @@ -179,24 +216,7 @@ export async function handler(event: ProcessorEvent): Promise { ); return; } - const repo = mapping.Item.repo as string; - const labelFilter = (mapping.Item.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER; - - // Only trigger when the configured label is present AND this event is a transition - // that meaningfully added/asserts the label — `create` with the label on it, or - // `update` that newly added it. - if (!shouldTrigger(payload, labelFilter)) { - logger.info('Linear webhook does not match trigger criteria', { - action: payload.action, - issue_id: issue.id, - label_filter: labelFilter, - current_labels: issue.labels?.map((l) => l?.name), - updated_from_keys: Object.keys(payload.updatedFrom ?? {}), - updated_from_label_ids: payload.updatedFrom?.labelIds, - current_label_ids: issue.labels?.map((l) => l?.id), - }); - return; - } + const repo = mappingItem.repo as string; // Resolve the actor → platform user. Fall back to creator if the actor is missing // (e.g. automation that set the label). If neither resolves, we cannot attribute diff --git a/cdk/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts new file mode 100644 index 00000000..3d8d85e1 --- /dev/null +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -0,0 +1,354 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Sha256 } from '@aws-crypto/sha256-js'; +import { + BedrockAgentCoreClient, + StartBrowserSessionCommand, + StopBrowserSessionCommand, +} from '@aws-sdk/client-bedrock-agentcore'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; +import WebSocket, { type RawData } from 'ws'; +import { logger } from './logger'; + +const REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'; + +/** + * AWS-managed default browser identifier. AgentCore Browser publishes a + * shared browser at this id without provisioning. (We could call + * `CreateBrowser` to get a dedicated one, but the screenshot path + * doesn't need any custom config — keep it simple.) + */ +const AWS_BROWSER_IDENTIFIER = 'aws.browser.v1'; + +/** + * Default budget for the entire screenshot job (start session → navigate + * → screenshot → stop). Lambda timeout should be at least 15s above this + * to leave headroom for the JSON encode + S3 PUT after the screenshot. + */ +const DEFAULT_TIMEOUT_MS = 60_000; + +/** CDP message id allocator. */ +let nextCdpId = 1; + +interface CdpMessage { + readonly id?: number; + readonly method?: string; + readonly params?: Record; + readonly sessionId?: string; + readonly result?: Record; + readonly error?: { code: number; message: string }; +} + +/** + * Capture a full-page PNG screenshot of `url` via AgentCore Browser. + * + * Implementation notes: + * - Uses the native `WebSocket` (Node 24+) and speaks Chrome DevTools + * Protocol directly. Avoids pulling in Playwright / puppeteer-core + * into the Lambda bundle (would be ~150 MB). + * - The automation WSS endpoint requires a SigV4-signed handshake + * request. Browser session creation is a normal SigV4 SDK call; + * once the session is created, the WSS upgrade GET also needs + * SigV4 headers in `Sec-WebSocket-*` companion form. Node's + * `WebSocket` constructor accepts a custom `Headers` object via + * the `protocols`/`headers` slot in `clientOptions`. + * - The flow is intentionally minimal: + * 1. StartBrowserSession (REST API; SDK call) + * 2. WS connect to the automation streamEndpoint (SigV4 handshake) + * 3. CDP `Target.attachToBrowserTarget` to get a flat session + * 4. CDP `Target.getTargets`, find the about:blank page + * 5. `Target.attachToTarget` (flatten=true) on that page → sessionId + * 6. `Page.navigate` + wait for `Page.frameStoppedLoading` + * 7. `Page.captureScreenshot` (returns base64 PNG) + * 8. StopBrowserSession (best-effort; sessions auto-expire) + * + * We don't try to be clever about fonts, viewports, or cookie + * injection — the agent is just snapshotting 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()); + + // Promise machinery for tracking in-flight CDP requests by `id`. + const pending = new Map void; reject: (err: Error) => void }>(); + const eventQueue: CdpMessage[] = []; + // Each waiter has a predicate; on each incoming event we deliver to the + // FIRST waiter whose predicate matches, otherwise queue the event. + interface EventWaiter { + readonly predicate: (msg: CdpMessage) => boolean; + readonly resolve: (msg: CdpMessage) => void; + } + const eventWaiters: EventWaiter[] = []; + + ws.on('message', (raw: RawData) => { + const data = raw.toString(); + let msg: CdpMessage; + try { + msg = JSON.parse(data) as CdpMessage; + } catch { + return; + } + if (typeof msg.id === 'number') { + const slot = pending.get(msg.id); + if (slot) { + pending.delete(msg.id); + if (msg.error) { + slot.reject(new Error(`CDP error ${msg.error.code}: ${msg.error.message}`)); + } else { + slot.resolve(msg); + } + } + } else if (msg.method) { + const waiterIdx = eventWaiters.findIndex((w) => w.predicate(msg)); + if (waiterIdx !== -1) { + const [waiter] = eventWaiters.splice(waiterIdx, 1); + waiter.resolve(msg); + } else { + eventQueue.push(msg); + } + } + }); + + // Open the socket. `ws` exposes node-style EventEmitter; the + // `unexpected-response` event surfaces HTTP-level handshake failures + // (e.g. 403 from misaligned SigV4) so we can log a meaningful error + // instead of an empty `error` event. + await new Promise((resolve, reject) => { + const onOpen = (): void => { + cleanup(); + resolve(); + }; + const onError = (err: Error): void => { + cleanup(); + reject(new Error(`AgentCore Browser WebSocket error: ${err.message || '(no message)'}`)); + }; + const onUnexpectedResponse = (_req: unknown, res: { statusCode?: number }): void => { + cleanup(); + reject(new Error(`AgentCore Browser WebSocket handshake failed: HTTP ${res.statusCode ?? '?'}`)); + }; + const cleanup = (): void => { + ws.removeListener('open', onOpen); + ws.removeListener('error', onError); + ws.removeListener('unexpected-response', onUnexpectedResponse); + }; + ws.on('open', onOpen); + ws.on('error', onError); + ws.on('unexpected-response', onUnexpectedResponse); + setTimeout(() => { + cleanup(); + reject(new Error(`AgentCore Browser WebSocket open timeout after ${timeoutMs}ms`)); + }, remaining()); + }); + + function cdpSend(method: string, params: Record = {}, sessionId?: string): Promise { + const id = nextCdpId++; + const message: CdpMessage = { id, method, params, ...(sessionId ? { sessionId } : {}) }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`CDP ${method} timed out after ${remaining()}ms`)); + }, remaining()); + pending.set(id, { + resolve: (msg) => { clearTimeout(timer); resolve(msg); }, + reject: (err) => { clearTimeout(timer); reject(err); }, + }); + ws.send(JSON.stringify(message)); + }); + } + + function waitForEvent(method: string): Promise { + const queued = eventQueue.findIndex((m) => m.method === method); + if (queued !== -1) { + const [match] = eventQueue.splice(queued, 1); + return Promise.resolve(match); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = eventWaiters.findIndex((w) => w.resolve === wrappedResolve); + if (idx !== -1) eventWaiters.splice(idx, 1); + reject(new Error(`Timed out waiting for CDP event ${method}`)); + }, remaining()); + const wrappedResolve = (msg: CdpMessage): void => { + clearTimeout(timer); + resolve(msg); + }; + eventWaiters.push({ + predicate: (msg) => msg.method === method, + resolve: wrappedResolve, + }); + }); + } + + try { + // 1. List existing targets, find the default about:blank page. + const targetsResp = await cdpSend('Target.getTargets'); + const targets = (targetsResp.result?.targetInfos as Array<{ targetId: string; type: string; url: string }> | undefined) ?? []; + const pageTarget = targets.find((t) => t.type === 'page'); + if (!pageTarget) { + throw new Error('No page target found in AgentCore Browser session'); + } + + // 2. Attach with flatten=true to get a sessionId we can route subsequent commands to. + const attachResp = await cdpSend('Target.attachToTarget', { + targetId: pageTarget.targetId, + flatten: true, + }); + const pageSessionId = attachResp.result?.sessionId as string | undefined; + if (!pageSessionId) { + throw new Error('Target.attachToTarget did not return a sessionId'); + } + + // 3. Enable Page domain so we get frameStoppedLoading events. + await cdpSend('Page.enable', {}, pageSessionId); + + // 4. Navigate. The response includes a `frameId`; we wait on the + // `Page.loadEventFired` event below (more reliable than + // `frameStoppedLoading` which can fire before navigation + // actually starts on `about:blank` → real-URL transitions). + const navResp = await cdpSend('Page.navigate', { url }, pageSessionId); + const navError = navResp.result?.errorText as string | undefined; + if (navError) { + throw new Error(`Page.navigate failed: ${navError}`); + } + + // 5. Wait for the page load event. SPA-style apps may continue + // fetching after this fires, so add a 2s settle wait. For + // 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 = shotResp.result?.data as string | undefined; + if (!base64) { + throw new Error('Page.captureScreenshot returned no data'); + } + return Buffer.from(base64, 'base64'); + } finally { + try { ws.close(); } catch { /* ignore */ } + } +} + +/** + * Presign the WSS URL with SigV4 query parameters. AgentCore Browser + * accepts auth either as headers on the upgrade GET or as query params + * on the URL itself; the latter is more robust through WebSocket + * clients that rewrite Host headers (e.g. `ws`). + * + * Returns a `wss://...?X-Amz-Algorithm=...&X-Amz-Credential=...&...` + * URL ready to pass straight to `new WebSocket(...)`. + */ +async function sigV4PresignWss(wssUrl: string): Promise { + const u = new URL(wssUrl); + const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region: REGION, + credentials: defaultProvider(), + sha256: Sha256, + applyChecksum: false, + }); + + // Convert wss:// → https:// for the signing request (SigV4 doesn't + // know about wss). The signature is over the path + query, so the + // protocol on the signed request is irrelevant — we paste the auth + // params back onto the original wss:// URL. + const queryEntries = Array.from(u.searchParams.entries()); + const query: Record = {}; + for (const [k, v] of queryEntries) query[k] = v; + + const req = new HttpRequest({ + method: 'GET', + protocol: 'https:', + hostname: u.hostname, + path: u.pathname, + query, + headers: { host: u.hostname }, + }); + + // 60s expiry is fine — we open the socket immediately after signing. + const presigned = await signer.presign(req, { expiresIn: 60 }); + const out = new URL(wssUrl); + for (const [k, v] of Object.entries(presigned.query ?? {})) { + out.searchParams.set(k, Array.isArray(v) ? v[0] : (v as string)); + } + return out.toString(); +} diff --git a/cdk/src/handlers/shared/github-webhook-verify.ts b/cdk/src/handlers/shared/github-webhook-verify.ts new file mode 100644 index 00000000..1023686d --- /dev/null +++ b/cdk/src/handlers/shared/github-webhook-verify.ts @@ -0,0 +1,127 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { logger } from './logger'; + +const sm = new SecretsManagerClient({}); + +/** + * In-memory secret cache (5-minute TTL). Same pattern as `linear-verify.ts` + * — webhook secrets rotate infrequently, and skipping a Secrets Manager + * round-trip on every webhook keeps the receiver well under GitHub's 10s + * timeout. After rotation, the verifier transparently re-fetches once. + */ +const secretCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Fetch a GitHub webhook secret from Secrets Manager with caching. + * @param secretId - the Secrets Manager secret ID or ARN. + * @param forceRefresh - bypass cache and re-fetch. + * @returns the secret string, or null if not found. + */ +export async function getGitHubWebhookSecret(secretId: string, forceRefresh = false): Promise { + const now = Date.now(); + if (!forceRefresh) { + const cached = secretCache.get(secretId); + if (cached && cached.expiresAt > now) { + return cached.secret; + } + } + + try { + const result = await sm.send(new GetSecretValueCommand({ SecretId: secretId })); + if (!result.SecretString) { + secretCache.delete(secretId); + return null; + } + secretCache.set(secretId, { secret: result.SecretString, expiresAt: now + CACHE_TTL_MS }); + return result.SecretString; + } catch (err) { + const errorName = (err as Error)?.name; + if (errorName === 'ResourceNotFoundException') { + logger.error('GitHub webhook secret not found', { secret_id: secretId }); + secretCache.delete(secretId); + return null; + } + logger.error('Failed to fetch GitHub webhook secret', { + secret_id: secretId, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +/** Drop a cached webhook secret — used on suspected rotation. */ +export function invalidateGitHubWebhookSecretCache(secretId: string): void { + secretCache.delete(secretId); +} + +/** + * Verify a GitHub webhook signature. + * + * GitHub signs with HMAC-SHA256 over the raw body, hex-encoded, prefixed + * with the literal `sha256=` and delivered in the `X-Hub-Signature-256` + * header (per + * https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries). + * The legacy `X-Hub-Signature` (SHA1) header is not validated — GitHub + * always sends both, but SHA256 is the secure one. + * + * @param webhookSecret - the per-webhook signing secret. + * @param header - the `X-Hub-Signature-256` header value (with `sha256=` prefix). + * @param body - the raw request body string. + * @returns true if the signature matches. + */ +export function verifyGitHubSignature(webhookSecret: string, header: string, body: string): boolean { + if (!header.startsWith('sha256=')) { + return false; + } + const provided = header.slice('sha256='.length); + const expected = crypto.createHmac('sha256', webhookSecret).update(body).digest('hex'); + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided)); + } catch (err) { + logger.warn('GitHub signature comparison failed', { + error: err instanceof Error ? err.message : String(err), + expected_length: expected.length, + provided_length: provided.length, + }); + return false; + } +} + +/** + * Verify a GitHub webhook request, with one transparent re-fetch on + * cache miss. Same UX as `verifyLinearRequest` so warm Lambdas don't + * silently reject post-rotation deliveries for up to 5 minutes. + */ +export async function verifyGitHubRequest(secretId: string, header: string, body: string): Promise { + const cached = await getGitHubWebhookSecret(secretId); + if (cached && verifyGitHubSignature(cached, header, body)) { + return true; + } + + invalidateGitHubWebhookSecretCache(secretId); + const fresh = await getGitHubWebhookSecret(secretId, true); + if (!fresh) return false; + if (fresh === cached) return false; + return verifyGitHubSignature(fresh, header, body); +} diff --git a/cdk/src/handlers/shared/linear-issue-lookup.ts b/cdk/src/handlers/shared/linear-issue-lookup.ts new file mode 100644 index 00000000..4ce4a6bd --- /dev/null +++ b/cdk/src/handlers/shared/linear-issue-lookup.ts @@ -0,0 +1,187 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { resolveLinearOauthToken } from './linear-oauth-resolver'; +import { logger } from './logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +/** + * Linear issue identifier shape, e.g. `ABCA-42`. Linear identifiers are + * `-` where the key is uppercase letters and digits is + * a positive integer. We bound the team key length [1,10] and number + * length [1,8] to avoid pathological inputs. + */ +const LINEAR_IDENTIFIER_RE = /\b([A-Z][A-Z0-9]{0,9})-(\d{1,8})\b/g; + +/** + * Pull the first Linear issue identifier (e.g. `ABCA-42`) found in + * the given text. PR titles and bodies typically include this either + * because the agent's task_description carries the identifier, or + * because Linear's own GitHub integration auto-injects an + * `ABCA-42 ` reference. + * + * Returns the first match in document order. If multiple distinct + * identifiers are present we still return the first — multi-issue PRs + * are unusual enough that single-screenshot-per-issue is acceptable. + */ +export function extractLinearIdentifier(text: string | null | undefined): string | null { + if (!text) return null; + const match = LINEAR_IDENTIFIER_RE.exec(text); + // The regex has the `g` flag for testability; reset lastIndex so + // back-to-back calls behave correctly. + LINEAR_IDENTIFIER_RE.lastIndex = 0; + return match ? `${match[1]}-${match[2]}` : null; +} + +/** + * Resolved Linear issue location, paired with the workspace that owns + * it. The screenshot processor uses these to construct a + * LinearFeedbackContext + issueId for postIssueComment. + */ +export interface LinearIssueLocation { + readonly issueId: string; + readonly linearWorkspaceId: string; + readonly workspaceSlug: string; +} + +const ISSUE_BY_IDENTIFIER_QUERY = ` +query IssueByIdentifier($identifier: String!) { + issueVcsBranchSearch(branchName: $identifier) { + id + identifier + } +} +`.trim(); + +/** + * Look up a Linear issue by identifier (e.g. `ABCA-42`) by iterating + * over every active workspace in the registry until one returns a + * match. Returns the first hit. + * + * For v1 this scan is cheap — typical deployments have 1-2 workspaces. + * If a stack ever onboards many workspaces sharing identifier prefixes, + * a followup can store team_key prefixes on the registry row and route + * directly. Until then, linear-time iteration is fine. + * + * @param identifier `ABCA-42`-style Linear issue identifier + * @param registryTableName name of LinearWorkspaceRegistryTable + * @returns issue location, or null if no workspace contains the issue + */ +export async function findLinearIssueByIdentifier( + identifier: string, + registryTableName: string, +): Promise { + let active: Array<{ linear_workspace_id: string; workspace_slug: string }> = []; + try { + const scanResp = await ddb.send(new ScanCommand({ + TableName: registryTableName, + FilterExpression: '#s = :active', + ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { ':active': 'active' }, + })); + active = (scanResp.Items ?? []).map((item) => ({ + linear_workspace_id: item.linear_workspace_id as string, + workspace_slug: item.workspace_slug as string, + })); + } catch (err) { + logger.warn('Linear issue lookup: failed to scan workspace registry', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (active.length === 0) { + logger.info('Linear issue lookup: no active workspaces in registry', { identifier }); + return null; + } + + for (const ws of active) { + const resolved = await resolveLinearOauthToken(ws.linear_workspace_id, registryTableName); + if (!resolved) continue; + + const found = await queryIssueByIdentifier(resolved.accessToken, identifier); + if (found) { + return { + issueId: found, + linearWorkspaceId: ws.linear_workspace_id, + workspaceSlug: ws.workspace_slug, + }; + } + } + return null; +} + +/** + * Issue the GraphQL query to Linear; return the issue UUID on hit, null + * on miss. Never throws — caller iterates onto the next workspace. + * + * Uses `issueVcsBranchSearch` because it accepts the human-readable + * identifier directly (the regular `issue(id:)` query needs a UUID, + * which we don't have yet). The branch-search API was designed for + * exactly this — VCS integrations resolving `-` strings to + * issue rows. + */ +async function queryIssueByIdentifier(accessToken: string, identifier: string): Promise { + let resp: Response; + try { + resp = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: ISSUE_BY_IDENTIFIER_QUERY, + variables: { identifier }, + }), + }); + } catch (err) { + logger.warn('Linear issue lookup: graphql request failed', { + identifier, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (!resp.ok) { + logger.warn('Linear issue lookup: graphql non-2xx', { identifier, status: resp.status }); + return null; + } + + const body = (await resp.json()) as { + data?: { issueVcsBranchSearch?: { id?: string; identifier?: string } | null }; + errors?: unknown; + }; + if (body.errors) { + logger.warn('Linear issue lookup: graphql errors', { identifier, errors: body.errors }); + return null; + } + const hit = body.data?.issueVcsBranchSearch; + if (!hit?.id) return null; + // Sanity: the response identifier must match what we asked for. + // `issueVcsBranchSearch` is a fuzzy match against branch-name patterns; + // exact-match the identifier to avoid linking to a near-neighbor issue. + if (hit.identifier && hit.identifier.toUpperCase() !== identifier.toUpperCase()) { + return null; + } + return hit.id; +} diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 9ab63dd6..63105841 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -42,6 +42,7 @@ import { ConcurrencyReconciler } from '../constructs/concurrency-reconciler'; import { DnsFirewall } from '../constructs/dns-firewall'; // import { EcsAgentCluster } from '../constructs/ecs-agent-cluster'; import { FanOutConsumer } from '../constructs/fanout-consumer'; +import { GitHubScreenshotIntegration } from '../constructs/github-screenshot-integration'; import { LinearIntegration } from '../constructs/linear-integration'; import { PendingUploadCleanup } from '../constructs/pending-upload-cleanup'; import { RepoTable } from '../constructs/repo-table'; @@ -834,6 +835,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/handlers/linear-webhook-processor.test.ts b/cdk/test/handlers/linear-webhook-processor.test.ts index f93bfb2e..14cb24b3 100644 --- a/cdk/test/handlers/linear-webhook-processor.test.ts +++ b/cdk/test/handlers/linear-webhook-processor.test.ts @@ -62,7 +62,7 @@ function issue(overrides: Record = {}): Record description: 'Users cannot log in.', projectId: 'project-1', teamId: 'team-1', - labels: [{ id: 'lbl-bg', name: 'bgagent' }], + labels: [{ id: 'lbl-abca', name: 'abca' }], }, ...overrides, }; @@ -143,7 +143,7 @@ describe('linear-webhook-processor handler', () => { ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); const payload = issue({ action: 'update', - updatedFrom: { labelIds: ['lbl-bg', 'lbl-other'] }, + updatedFrom: { labelIds: ['lbl-abca', 'lbl-other'] }, }); await handler(eventWith(payload)); expect(createTaskCoreMock).not.toHaveBeenCalled(); @@ -159,7 +159,7 @@ describe('linear-webhook-processor handler', () => { test('creates task with channel_source=linear and linear_* metadata', async () => { ddbSend - .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active', label_filter: 'bgagent' } }) + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) .mockResolvedValueOnce({ Item: { linear_identity: 'org-1#user-1', @@ -356,6 +356,36 @@ describe('linear-webhook-processor handler', () => { expect(reportIssueFailureMock).not.toHaveBeenCalled(); }); + test('unlabeled issue in a NON-onboarded project is a silent no-op (regression: comment-spam)', async () => { + // Workspace webhooks fire workspace-wide — issues in teams that ABCA + // was never onboarded into still reach this Lambda. Previously, every + // such event posted a "❌ project isn't onboarded" comment, producing + // 47 identical comments in 5min on a single GRO issue. The label gate + // now runs FIRST, so an unlabeled issue produces zero side effects no + // matter what state the project mapping is in. + ddbSend.mockResolvedValueOnce({ Item: undefined }); + const payload = issue(); + (payload.data as Record).labels = [{ id: 'l2', name: 'other' }]; + + await handler(eventWith(payload)); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('unlabeled issue with no projectId is a silent no-op', async () => { + const payload = issue(); + const data = { ...(payload.data as Record) }; + delete data.projectId; + data.labels = [{ id: 'l2', name: 'other' }]; + payload.data = data; + + await handler(eventWith(payload)); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + test('safeReportIssueFailure: synchronous throw from reportIssueFailure does not propagate', async () => { // Defends against a future signature refactor that breaks the helper's // never-throw contract. Today `Promise.allSettled` guarantees this; if 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 9f14b2d8..be9dfe92 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..c610f85c --- /dev/null +++ b/docs/guides/DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md @@ -0,0 +1,204 @@ +# 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 excluded from the AWSManagedRulesCommonRuleSet because deployment_status payloads (which embed absolute deploy URLs) trip `GenericRFI_BODY` otherwise. + +## Prerequisites + +- ABCA stack deployed (`mise //cdk:deploy`) — confirm `GitHubWebhookUrl` + `GitHubWebhookSecretArn` + `ScreenshotCloudFrontDomain` are listed in the stack outputs +- (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 used in 4b. The CLI writes it to the stack's `GitHubWebhookSecret` Secrets Manager entry, where the receiver Lambda reads it for HMAC verification. + +### Step 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//.png +``` + +If it 403s, check that the bucket policy includes the OAC service principal (CDK should generate this automatically — re-deploy if it doesn't). + +### 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 grants `bedrock-agentcore:*`; tightening to the specific Browser action set is preferable. +- **Sensitive content.** If your previews include PII or other regulated content, consider CloudFront access logs + a WAF in front of the public CDN, and shorten screenshot retention below the 30-day default (constant in `cdk/src/constructs/screenshot-bucket.ts`). diff --git a/docs/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 cf53fd6b..7e190b7e 100644 --- a/docs/guides/ROADMAP.md +++ b/docs/guides/ROADMAP.md @@ -76,6 +76,7 @@ What's shipped and what's coming next. - [x] **GitHub edit-in-place** - Single status comment per task on the target PR, edited in place as progress events fire (phase, milestone, cost, link) - [x] **Routable agent milestones** - Named checkpoints (`pr_created`, `nudge_acknowledged`) unwrapped against allowlist for channel filter matching - [x] **Slack notification dispatcher** - FanOut Block Kit messages for Slack-origin tasks (lifecycle events, threaded replies, terminal dedup, in-thread cancel). Generic fallback text for unmapped event types (e.g. some milestones); richer milestone and approval-gate rendering is follow-up work +- [x] **Deploy-preview screenshots** - Listens for GitHub `deployment_status: success` events from any provider (Vercel, Amplify Hosting, Netlify, GitHub Actions); captures the preview URL via AgentCore Browser; posts a markdown image comment on the open PR (and on the linked Linear issue if Linear is configured). Lambda-only, deterministic, ~10–15 s post-deploy. See [Deploy preview screenshots guide](./DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md). - [ ] **Email dispatcher** - Log-only stub; pending SES integration ### Channels diff --git a/docs/guides/USER_GUIDE.md b/docs/guides/USER_GUIDE.md index 417a2053..af9eeb96 100644 --- a/docs/guides/USER_GUIDE.md +++ b/docs/guides/USER_GUIDE.md @@ -1014,6 +1014,14 @@ The notification plane uses DynamoDB Streams to fan out task events to channel-s The status comment shows: current phase, last milestone, cost so far, and a link to the task. It updates on key events (`session_started`, `pr_created`, `task_completed`, `task_failed`, `nudge_acknowledged`, and routable agent milestones). +### Preview-deploy screenshots (optional) + +If your repo is wired to a deploy provider that publishes GitHub `deployment_status` events (Vercel, Amplify Hosting, Netlify, GitHub Actions custom CD, etc.), ABCA can capture a full-page screenshot of each preview URL and post it as an image comment on the open PR — and on the linked Linear issue if Linear is configured. + +This runs independently of the agent: there's no LLM involved, just a Lambda that drives a headless browser via AgentCore Browser. End-to-end latency is typically 10–15 seconds after the deploy provider reports success. + +Setup is opt-in and per-repo. See the [Deploy preview screenshots guide](./DEPLOY_PREVIEW_SCREENSHOTS_GUIDE.md) for the wiring (one webhook on the repo, one secret pasted into AWS). + ## What the agent does The agent is the part of the platform that actually writes code. When the orchestrator finishes preparing a task (admission, context hydration, pre-flight checks), it hands off to an agent running inside an isolated compute environment. Today the platform supports **Amazon Bedrock AgentCore Runtime** as the default compute backend - each agent session runs in a Firecracker MicroVM with session-scoped storage and automatic cleanup. The architecture is designed to support additional compute backends (ECS on Fargate, ECS on EC2) for repositories that need more resources or custom toolchains beyond the AgentCore 2 GB image limit. See the [Compute design](/sample-autonomous-cloud-coding-agents/architecture/compute) for the full comparison. diff --git a/docs/scripts/sync-starlight.mjs b/docs/scripts/sync-starlight.mjs index b28c3075..d9b22b8e 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 2b6e4d2b..484e52d3 100644 --- a/docs/src/content/docs/roadmap/Roadmap.md +++ b/docs/src/content/docs/roadmap/Roadmap.md @@ -80,6 +80,7 @@ What's shipped and what's coming next. - [x] **GitHub edit-in-place** - Single status comment per task on the target PR, edited in place as progress events fire (phase, milestone, cost, link) - [x] **Routable agent milestones** - Named checkpoints (`pr_created`, `nudge_acknowledged`) unwrapped against allowlist for channel filter matching - [x] **Slack notification dispatcher** - FanOut Block Kit messages for Slack-origin tasks (lifecycle events, threaded replies, terminal dedup, in-thread cancel). Generic fallback text for unmapped event types (e.g. some milestones); richer milestone and approval-gate rendering is follow-up work +- [x] **Deploy-preview screenshots** - Listens for GitHub `deployment_status: success` events from any provider (Vercel, Amplify Hosting, Netlify, GitHub Actions); captures the preview URL via AgentCore Browser; posts a markdown image comment on the open PR (and on the linked Linear issue if Linear is configured). Lambda-only, deterministic, ~10–15 s post-deploy. See [Deploy preview screenshots guide](/using/deploy-preview-screenshots-guide). - [ ] **Email dispatcher** - Log-only stub; pending SES integration ### Channels diff --git a/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md new file mode 100644 index 00000000..5d19517a --- /dev/null +++ b/docs/src/content/docs/using/Deploy-preview-screenshots-guide.md @@ -0,0 +1,208 @@ +--- +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 excluded from the AWSManagedRulesCommonRuleSet because deployment_status payloads (which embed absolute deploy URLs) trip `GenericRFI_BODY` otherwise. + +## Prerequisites + +- ABCA stack deployed (`mise //cdk:deploy`) — confirm `GitHubWebhookUrl` + `GitHubWebhookSecretArn` + `ScreenshotCloudFrontDomain` are listed in the stack outputs +- (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 used in 4b. The CLI writes it to the stack's `GitHubWebhookSecret` Secrets Manager entry, where the receiver Lambda reads it for HMAC verification. + +### Step 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//.png +``` + +If it 403s, check that the bucket policy includes the OAC service principal (CDK should generate this automatically — re-deploy if it doesn't). + +### 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 grants `bedrock-agentcore:*`; tightening to the specific Browser action set is preferable. +- **Sensitive content.** If your previews include PII or other regulated content, consider CloudFront access logs + a WAF in front of the public CDN, and shorten screenshot retention below the 30-day default (constant in `cdk/src/constructs/screenshot-bucket.ts`). diff --git a/docs/src/content/docs/using/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 da61a951..514d48d2 100644 --- a/docs/src/content/docs/using/Task-lifecycle.md +++ b/docs/src/content/docs/using/Task-lifecycle.md @@ -101,4 +101,12 @@ When a task targets a pull request (`pr_iteration` or `pr_review`), the platform The notification plane uses DynamoDB Streams to fan out task events to channel-specific dispatchers. Currently the GitHub edit-in-place dispatcher is active; Slack and Email dispatchers are planned. -The status comment shows: current phase, last milestone, cost so far, and a link to the task. It updates on key events (`session_started`, `pr_created`, `task_completed`, `task_failed`, `nudge_acknowledged`, and routable agent milestones). \ No newline at end of file +The status comment shows: current phase, last milestone, cost so far, and a link to the task. It updates on key events (`session_started`, `pr_created`, `task_completed`, `task_failed`, `nudge_acknowledged`, and routable agent milestones). + +### Preview-deploy screenshots (optional) + +If your repo is wired to a deploy provider that publishes GitHub `deployment_status` events (Vercel, Amplify Hosting, Netlify, GitHub Actions custom CD, etc.), ABCA can capture a full-page screenshot of each preview URL and post it as an image comment on the open PR — and on the linked Linear issue if Linear is configured. + +This runs independently of the agent: there's no LLM involved, just a Lambda that drives a headless browser via AgentCore Browser. End-to-end latency is typically 10–15 seconds after the deploy provider reports success. + +Setup is opt-in and per-repo. See the [Deploy preview screenshots guide](/using/deploy-preview-screenshots-guide) for the wiring (one webhook on the repo, one secret pasted into AWS). \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 32239b57..d7245c24 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5596,6 +5596,13 @@ resolved "https://registry.yarnpkg.com/@types/unist/-/unist-2.0.11.tgz#11af57b127e32487774841f7a4e54eab166d03c4" integrity sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA== +"@types/ws@^8.5.13": + version "8.18.1" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.18.1.tgz#48464e4bf2ddfd17db13d845467f6070ffea4aa9" + integrity sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg== + dependencies: + "@types/node" "*" + "@types/yargs-parser@*": version "21.0.3" resolved "https://registry.yarnpkg.com/@types/yargs-parser/-/yargs-parser-21.0.3.tgz#815e30b786d2e8f0dcd85fd5bcf5e1a04d008f15" @@ -11167,6 +11174,11 @@ write-file-atomic@^5.0.1: imurmurhash "^0.1.4" signal-exit "^4.0.1" +ws@^8.18.0: + version "8.20.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" + integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== + xml-naming@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.1.0.tgz#8ab7106c5b8d23caa2fabac1cadf17136379fbd8"