Skip to content

feat(screenshot): Vercel preview screenshot → GitHub PR + Linear issue#3

Open
isadeks wants to merge 14 commits into
feat/agentcore-oauth-2-0bfrom
feat/screenshot-on-push
Open

feat(screenshot): Vercel preview screenshot → GitHub PR + Linear issue#3
isadeks wants to merge 14 commits into
feat/agentcore-oauth-2-0bfrom
feat/screenshot-on-push

Conversation

@isadeks
Copy link
Copy Markdown
Owner

@isadeks isadeks commented May 21, 2026

Stacked on feat/agentcore-oauth-2-0b (which is open as upstream PR aws-samples#160). When that lands, this branch will rebase onto main and a fresh PR can target the upstream repo.

Summary

After ABCA opens a PR for a Linear-driven task, Vercel deploys the preview, posts a deployment_status event back to GitHub, and ABCA's webhook receiver:

  1. Captures a full-page screenshot of the preview URL via AgentCore Browser
  2. Uploads the PNG to a private S3 bucket served via CloudFront
  3. Posts a markdown image comment on the open GitHub PR
  4. Looks up the Linear issue (by identifier in the PR title/body) and posts the same screenshot as a Linear comment

Smoke-validated end-to-end on backgroundagent-dev: Linear issue ABCA-70 → vercel-abca-linear PR #2 → screenshot landed on both GitHub PR and Linear issue, ~10s after Vercel reported the deploy.

Architecture

  • Lambda-only. No agent runtime is involved post-PR — the screenshot job is deterministic.
  • AgentCore Browser (AWS-managed aws.browser.v1) driven over CDP via the ws package, with SigV4-presigned WSS URL. Avoids Playwright bloat in the Lambda bundle.
  • Private S3 + CloudFront with OAC. Account-level S3 Block Public Access blocks plain public-read buckets, so CloudFront fronts the bucket; both GitHub Markdown and Linear's image preview can render the URL anonymously.
  • WAF exemption. /v1/github/webhook is excluded from AWSManagedRulesCommonRuleSet for the same reason /v1/linear/webhook is — webhook payloads with absolute URLs trip GenericRFI_BODY otherwise.
  • Retry on no-PR. Vercel commonly posts deployment_status 5-15s before the agent's gh pr create returns; the processor retries the PR lookup with backoff (0s/5s/10s/20s) to handle the race.

What's in this PR

  • New construct + handlers under cdk/src/{constructs,handlers}/ for the GitHub webhook → screenshot pipeline
  • WAF CRS exemption for the new webhook path
  • Three new stack outputs (GitHubWebhookUrl, GitHubWebhookSecretArn, ScreenshotCloudFrontDomain)
  • bgagent linear list-projects rewritten to use OAuth (was still on the parked PAK secret)
  • docs/guides/VERCEL_SETUP_GUIDE.md — operator-facing setup walkthrough + troubleshooting

Test plan

Followups (tracked, not blocking)

  • Scope IAM down from bedrock-agentcore:* to the specific Browser action once discoverable via CloudTrail
  • bgagent github webhook-info CLI command for setup ergonomics
  • Unit tests for the four new handlers
  • Prefix-routing for the Linear workspace lookup (today scans all active workspaces)
  • Production hardening: Vercel Standard Protection + signed bypass token injection
  • ARCHITECTURE.md / COST_MODEL.md / USER_GUIDE.md / ROADMAP.md updates

bgagent and others added 14 commits May 20, 2026 13:57
… wiring yet)

Lands the runtime pieces of the screenshot-on-preview-deploy feature:

- `ScreenshotBucket` construct (`cdk/src/constructs/screenshot-bucket.ts`):
  public-read on `screenshots/*`, SSE-S3, 30-day TTL. Bucket policy
  scoped to the prefix so anything written outside is invisible.

- GitHub webhook receiver (`cdk/src/handlers/github-webhook.ts`):
  HMAC-verifies `X-Hub-Signature-256`, filters to
  `deployment_status` events with `state=success` and
  `environment=Preview`, dedups on `(repo, deployment_id, status_id)`,
  async-invokes the processor. Topology mirrors `linear-webhook.ts`.

- Webhook processor (`cdk/src/handlers/github-webhook-processor.ts`):
  Looks up the open PR for the deploy SHA via the GitHub Commits API,
  captures a screenshot of `deployment.environment_url` via AgentCore
  Browser, PUTs the PNG to the screenshot bucket, posts a markdown
  embed in a fresh PR comment.

- AgentCore Browser wrapper (`cdk/src/handlers/shared/agentcore-browser.ts`):
  Drives Chrome DevTools Protocol over WebSocket directly, avoiding
  Playwright bloat. SigV4-signs the WSS handshake. Smoke-tested locally
  against example.com and a Vercel demo URL — 6.5s end-to-end, valid PNG.

- GitHub webhook verify helper (`cdk/src/handlers/shared/github-webhook-verify.ts`):
  Mirrors `linear-verify.ts` — secret cache with 5min TTL, transparent
  re-fetch once on signature failure.

Stack wiring (IAM grants, API Gateway route, Lambda construction)
is the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- New `GitHubScreenshotIntegration` construct (mirrors `LinearIntegration`):
  bundles the screenshot bucket, dedup table, signing-secret placeholder,
  receiver Lambda, processor Lambda, and the API Gateway route. cdk-nag
  suppressions added inline (HMAC auth instead of Cognito; AgentCore
  Browser sessions have no per-resource ARN; Secrets Manager rotation
  is owned by GitHub).

- Wired into `agent.ts` after the LinearIntegration block. Reuses the
  existing `githubTokenSecret` (the processor uses ABCA's main GitHub
  token to look up which PR a deploy SHA belongs to and post the
  screenshot comment — no new credential).

- Three new stack outputs: `GitHubWebhookUrl`, `GitHubWebhookSecretArn`,
  `ScreenshotBucketName`.

- Bumped agent.test.ts table count from 13 to 14 to account for the
  new dedup table.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ot bucket

cdk-nag's S2 fires on any bucket that has `blockPublicPolicy: false`
even when the policy is intentionally permissive. Add the suppression
with the same rationale as S1/S5 — public reads are required by
GitHub Markdown renderers and Linear `imageUploadFromUrl`, and the
read grant is prefix-scoped to `screenshots/*`.

Caught when the first deploy attempt aborted at synth-time on the new
GitHubScreenshotIntegration construct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first deploy attempt failed at CFN-execute time on the bucket
policy:

  s3:PutBucketPolicy ... because public policies are prevented by
  the BlockPublicPolicy setting in S3 Block Public Access.

Account-level Block Public Access is on for this AWS account, which
overrides per-bucket BPA settings. Disabling it would change the
security posture of the whole account, so route around the constraint
with the AWS-recommended pattern: private S3 + CloudFront with Origin
Access Control.

Changes:
- `ScreenshotBucket` is now `BLOCK_ALL` BPA, no public bucket policy.
  Adds a `cloudfront.Distribution` whose origin is the bucket via
  `S3BucketOrigin.withOriginAccessControl`. The distribution policy is
  scoped to the CloudFront service principal only, so account-level
  BPA accepts it.
- Processor reads `SCREENSHOT_PUBLIC_HOST` (the CloudFront domain)
  instead of building an S3 URL. PR comments now embed
  `https://<dist>.cloudfront.net/screenshots/...` URLs.
- New stack output `ScreenshotCloudFrontDomain`.
- Bucket-level S2/S5 suppressions removed (no longer applicable —
  bucket is private). Distribution gets CFR1/CFR2/CFR3/CFR4/CFR7
  suppressions with rationales.

Heads up on deploy time: CloudFront distributions take 5-15 min to
provision on first create.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The CommonRuleSet was 403'ing GitHub deployment_status webhooks before
the request reached our Lambda — the deployment payload contains
absolute Vercel preview URLs in the body, which trips GenericRFI_BODY.

Mirror the Linear webhook exemption: the GitHub webhook path is
HMAC-verified in the Lambda, parsed as strict JSON, never
interpolated into SQL/HTML, and rate-limited by the priority-3 rule.
CRS still applies to every other route.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…loyment

GitHub's `deployment_status` webhook puts the deployed URL on the
*status* object, not the deployment itself. The deployment object is
immutable per (sha, environment); the status changes through the
deploy lifecycle (`pending` → `success`) and carries the URL only
once the deploy finishes.

Symptom: receiver kept short-circuiting `success` events from Vercel
with `{ok: true, skipped_no_url: true}` because we read the wrong
field. Verified by inspecting the webhook delivery payload via
`gh api .../deliveries/<id> --jq .request.payload.deployment_status` —
URL was there all along.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…dshake

Node 24's global WebSocket (from undici) does NOT support arbitrary
HTTP headers on the upgrade request — passing them as the second arg
gets silently ignored. AgentCore Browser's WSS handshake requires
SigV4-signed Authorization + X-Amz-* headers, so the connection was
opening but then getting rejected, which surfaced as an empty
`error` event ("AgentCore Browser WebSocket error: ").

Switch to the `ws` package which natively supports `options.headers`.
Also add an `unexpected-response` handler so HTTP-level handshake
failures (403, 400) surface with status codes instead of empty errors.

Smoke verified locally — the ws-based path opens cleanly against
example.com and Vercel preview URLs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Lambda runtime returned a 403 on the WSS upgrade despite well-formed
SigV4 headers — `ws` rewrites the Host header during the upgrade
GET, which invalidates the canonical-request signature we computed
against the original Host. This works locally because Node's tooling
on macOS keeps the original Host through the handshake, but the
Lambda runtime's TLS stack normalizes differently.

Switch to query-parameter SigV4 (presigned URL): SignatureV4.presign
returns a wss://...?X-Amz-Algorithm=...&X-Amz-Signature=... URL where
the auth lives in the URL itself, so any Host-header rewriting
downstream doesn't break the signature.

Smoke verified locally — presigned URL connects cleanly to AgentCore
Browser and the screenshot pipeline runs end-to-end (6.3s, valid
PNG, captures example.com correctly).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The minimal IAM I shipped earlier (`StartBrowserSession`,
`StopBrowserSession`, `GetBrowserSession`, `UpdateBrowserStream`)
wasn't enough — the WSS automation-stream connect requires an
additional `ConnectBrowserAutomationStream`-flavored action that
isn't in the public CLI command list. Lambda invocations were
opening sessions cleanly but 403'ing on the WSS upgrade.

Widen to `bedrock-agentcore:*` to unblock the e2e flow. Followup:
scope back down to the specific connect action once it's documented
or surfaced via CloudTrail decoded-message-on-deny.

Smoke verified: PR #1 on isadeks/vercel-abca-linear now receives a
screenshot comment within ~7s of the deployment_status webhook.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends the screenshot processor to find a Linear issue via the PR's
title/body and post the same image comment there.

Approach (no GSI write-back needed):
- Regex-extract Linear identifier (e.g. `ABCA-42`) from PR title/body.
  These are present whether the agent put them there
  (`task_description` carries the identifier) or Linear's own GitHub
  integration auto-injected the back-reference on PR open.
- Scan `LinearWorkspaceRegistryTable` for `status=active` workspaces.
  Per-workspace, query Linear's `issueVcsBranchSearch` (which accepts
  the human-readable identifier) and accept the first exact-match
  hit.
- Post the markdown image comment via the existing `postIssueComment`
  helper from Phase 2.0b.

The Linear post is best-effort — if the registry table isn't wired,
the identifier doesn't extract, or the lookup misses, the GitHub PR
comment still lands. New env var `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME`
is optional on the processor; the construct only sets it when the
prop is provided.

CDK: `GitHubScreenshotIntegrationProps` gains an optional
`linearWorkspaceRegistryTable`. When provided, the processor's IAM
grows: ReadData on the registry, GetSecretValue+PutSecretValue on
`bgagent-linear-oauth-*`. `agent.ts` wires
`linearIntegration.workspaceRegistryTable` into the screenshot
construct.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The command still pulled from the parked PAK secret
(`LinearApiTokenSecretArn`), which we removed in Phase 2.0b. Symptom:
`Could not find LinearApiTokenSecretArn in stack outputs.`

Rewrite to scan Secrets Manager for `bgagent-linear-oauth-*` secrets
and query each workspace's projects with its own OAuth token. Supports
`--slug <slug>` to scope to one workspace; without it, queries every
installed workspace and labels each project with its source.

Also: switch to the `Bearer <token>` auth header and the
`teams(first: 1) { nodes { name } }` shape (the old `team` field on
Project no longer exists in Linear's GraphQL).

Adds a `LINEAR_OAUTH_SECRET_PREFIX` const in `linear-oauth.ts` to
keep the secret-name contract in one place.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vercel posts the success `deployment_status` webhook the moment its
build finishes, which on the Linear-driven path is ~7-15s before the
agent's `gh pr create` returns. The processor's first lookup against
the GitHub commit-pulls API came back empty and we'd silently drop
the screenshot.

Add a retry wrapper with backoff (0s, 5s, 10s, 20s — total max ~35s)
around the PR lookup. The first hit returns immediately, so the
warm-cache happy path is unchanged.

Verified end-to-end on backgroundagent-dev: Linear issue ABCA-70 →
agent → PR #2 in vercel-abca-linear → Vercel preview → screenshot
landed on both the GitHub PR and the Linear issue.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…issue comment spam

Move the trigger-label check ahead of every user-facing comment path in
the Linear webhook processor, and switch the default trigger label from
'bgagent' to 'abca'. An unlabeled issue is now a true no-op: no comment,
no reaction, no createTaskCore, no DDB writes — regardless of whether
the project is onboarded.

Why: workspace webhooks fire workspace-wide. A single un-onboarded team
in the same Linear workspace produced 47 identical "❌ project isn't
onboarded" comments on GRO-783 in 5 minutes because every Issue event
(create/update/label-change) hit the not-onboarded gate before the
label gate. With the gate order flipped, only issues that explicitly
opt in via the trigger label can ever generate user-facing feedback.

Per-project label_filter override is still respected — the project
mapping lookup now happens once, before the label gate, instead of after.

Tests: two new regression tests pin the spam scenario (unlabeled issue
in a non-onboarded project, and unlabeled issue with no projectId) to
zero side effects. Full CDK suite (89 suites / 1572 tests) passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Operator-facing setup walkthrough:
1. Connect Vercel to the GitHub repo
2. Vercel project settings (Git events on, Deployment Protection off
   for the demo, with a "production hardening" caveat for signed bypass)
3. Onboard the repo to ABCA (RepoTable put + bgagent linear onboard-project)
4. Configure the GitHub webhook (URL + secret from stack outputs,
   subscribe to Deployment statuses only)
5. Smoke test (label a Linear issue, watch screenshot land on PR + Linear)

Includes a troubleshooting section indexed by symptom (401/403 from
webhook, no comment lands, Linear post missing, CloudFront 403, Vercel
auth wall) and a forward-looking "production hardening" list for when
the feature graduates from demo.

Wires the new guide into the Starlight sync (docs/scripts/sync-starlight.mjs)
and sidebar (docs/astro.config.mjs).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant