From 3e59d9144765d8c86d1fe6d5f01855417386d717 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 13:57:36 -0700 Subject: [PATCH 001/190] feat(screenshot): Vercel preview-deploy screenshot pipeline (no stack wiring yet) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cdk/package.json | 4 + cdk/src/constructs/screenshot-bucket.ts | 140 ++++++++ cdk/src/handlers/github-webhook-processor.ts | 256 ++++++++++++++ cdk/src/handlers/github-webhook.ts | 244 +++++++++++++ cdk/src/handlers/shared/agentcore-browser.ts | 328 ++++++++++++++++++ .../handlers/shared/github-webhook-verify.ts | 127 +++++++ 6 files changed, 1099 insertions(+) create mode 100644 cdk/src/constructs/screenshot-bucket.ts create mode 100644 cdk/src/handlers/github-webhook-processor.ts create mode 100644 cdk/src/handlers/github-webhook.ts create mode 100644 cdk/src/handlers/shared/agentcore-browser.ts create mode 100644 cdk/src/handlers/shared/github-webhook-verify.ts diff --git a/cdk/package.json b/cdk/package.json index 48668607..a7a08889 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -17,6 +17,7 @@ "@aws-cdk/aws-bedrock-agentcore-alpha": "2.238.0-alpha.0", "@aws-cdk/aws-bedrock-alpha": "2.238.0-alpha.0", "@aws-cdk/mixins-preview": "2.238.0-alpha.0", + "@aws-crypto/sha256-js": "^5.2.0", "@aws-sdk/client-bedrock-agentcore": "^3.1046.0", "@aws-sdk/client-bedrock-runtime": "^3.1021.0", "@aws-sdk/client-dynamodb": "^3.1021.0", @@ -24,11 +25,14 @@ "@aws-sdk/client-lambda": "^3.1021.0", "@aws-sdk/client-s3": "^3.1021.0", "@aws-sdk/client-secrets-manager": "^3.1021.0", + "@aws-sdk/credential-provider-node": "^3.972.29", "@aws-sdk/lib-dynamodb": "^3.1021.0", "@aws-sdk/s3-presigned-post": "^3.1021.0", "@aws-sdk/s3-request-presigner": "^3.1021.0", "@aws/durable-execution-sdk-js": "^1.1.0", "@cedar-policy/cedar-wasm": "4.10.0", + "@smithy/protocol-http": "^5.3.12", + "@smithy/signature-v4": "^5.3.14", "aws-cdk-lib": "^2.238.0", "cdk-nag": "^2.37.55", "constructs": "^10.3.0", diff --git a/cdk/src/constructs/screenshot-bucket.ts b/cdk/src/constructs/screenshot-bucket.ts new file mode 100644 index 00000000..b97ae517 --- /dev/null +++ b/cdk/src/constructs/screenshot-bucket.ts @@ -0,0 +1,140 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import * as s3 from 'aws-cdk-lib/aws-s3'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; + +/** Lifecycle expiry for screenshot artifacts. */ +export const SCREENSHOT_TTL_DAYS = 30; + +/** + * Object-key prefix for all screenshots. Key layout: + * ``screenshots/.png``. The bucket policy grants public + * ``s3:GetObject`` on this prefix only — anything written outside is + * invisible to anonymous readers. + */ +export const SCREENSHOT_KEY_PREFIX = 'screenshots/'; + +/** + * Build the public HTTPS URL for a screenshot object. Path-style URL is + * intentional — virtual-hosted style breaks for buckets with dots in + * the name (CDK auto-generated names sometimes include dots when the + * region is appended). + */ +export function screenshotPublicUrl(bucket: s3.IBucket, key: string): string { + const region = Stack.of(bucket).region; + return `https://${bucket.bucketName}.s3.${region}.amazonaws.com/${key}`; +} + +/** + * Properties for ScreenshotBucket construct. + */ +export interface ScreenshotBucketProps { + /** + * Removal policy for the bucket. + * @default RemovalPolicy.DESTROY + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Whether to auto-delete objects when the bucket is removed. + * @default true + */ + readonly autoDeleteObjects?: boolean; +} + +/** + * S3 bucket hosting screenshot PNGs that the agent embeds in GitHub PR + * + Linear issue comments. + * + * The agent writes ``screenshots/.png`` after AgentCore Browser + * captures the deployed GitHub Pages URL. Both GitHub Markdown rendering + * and Linear's image previews fetch the URL anonymously, so the prefix + * is configured for unauthenticated reads. + * + * Security shape: + * - ``blockPublicAcls`` and ``ignorePublicAcls`` true — no per-object ACLs + * can grant access; only the bucket policy decides. + * - ``blockPublicPolicy`` and ``restrictPublicBuckets`` false — the policy + * intentionally grants public read on ``screenshots/*``. + * - Bucket policy: anonymous ``s3:GetObject`` limited to the + * ``screenshots/*`` key prefix and TLS-only transport. Writes still + * require IAM (the agent's runtime role). + * - SSE-S3 at rest, ``enforceSSL`` true. + * - 30-day lifecycle so screenshots don't accumulate forever. + */ +export class ScreenshotBucket extends Construct { + /** The underlying S3 bucket. */ + public readonly bucket: s3.Bucket; + + constructor(scope: Construct, id: string, props: ScreenshotBucketProps = {}) { + super(scope, id); + + this.bucket = new s3.Bucket(this, 'Bucket', { + // Allow public bucket policy (the next statement); deny public ACLs. + blockPublicAccess: new s3.BlockPublicAccess({ + blockPublicAcls: true, + ignorePublicAcls: true, + blockPublicPolicy: false, + restrictPublicBuckets: false, + }), + encryption: s3.BucketEncryption.S3_MANAGED, + enforceSSL: true, + lifecycleRules: [ + { + id: 'screenshot-ttl', + enabled: true, + expiration: Duration.days(SCREENSHOT_TTL_DAYS), + abortIncompleteMultipartUploadAfter: Duration.days(1), + }, + ], + removalPolicy: props.removalPolicy ?? RemovalPolicy.DESTROY, + autoDeleteObjects: props.autoDeleteObjects ?? true, + }); + + // Public read on the screenshots/ prefix only. Both GitHub markdown + // and Linear's `imageUploadFromUrl` need to GET the URL anonymously. + this.bucket.addToResourcePolicy(new iam.PolicyStatement({ + sid: 'AllowAnonymousReadOfScreenshotsPrefix', + effect: iam.Effect.ALLOW, + principals: [new iam.AnyPrincipal()], + actions: ['s3:GetObject'], + resources: [`${this.bucket.bucketArn}/${SCREENSHOT_KEY_PREFIX}*`], + conditions: { + Bool: { 'aws:SecureTransport': 'true' }, + }, + })); + + NagSuppressions.addResourceSuppressions(this.bucket, [ + { + id: 'AwsSolutions-S1', + reason: + 'Server access logs are not enabled for this bucket; screenshots are ephemeral artifacts (30-day TTL) embedded in GitHub PR comments and Linear issues. Adding access logging would generate substantial log volume for a low-value security signal — public reads are by design and the prefix is scoped to PNG renders only.', + }, + { + id: 'AwsSolutions-S5', + reason: + 'Public-read on screenshots/* is intentional — GitHub markdown renderers and Linear imageUploadFromUrl both require anonymous GET on the embedded image URL. Followup #79 will move to CloudFront with signed URLs once the feature stabilizes.', + }, + ], true); + } +} diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts new file mode 100644 index 00000000..54ba98d9 --- /dev/null +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -0,0 +1,256 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; +import { captureScreenshot } from './shared/agentcore-browser'; +import { resolveGitHubToken } from './shared/context-hydration'; +import { upsertTaskComment } from './shared/github-comment'; +import { logger } from './shared/logger'; + +const s3 = new S3Client({}); + +const SCREENSHOT_BUCKET = process.env.SCREENSHOT_BUCKET_NAME!; +const GITHUB_TOKEN_SECRET_ARN = process.env.GITHUB_TOKEN_SECRET_ARN!; +const REGION = process.env.AWS_REGION ?? 'us-east-1'; + +interface GitHubDeploymentStatusPayload { + readonly action?: string; + readonly deployment_status?: { + readonly id?: number; + readonly state?: string; + readonly target_url?: string; + }; + readonly deployment?: { + readonly id?: number; + readonly sha?: string; + readonly environment?: string; + readonly environment_url?: string; + }; + readonly repository?: { + readonly full_name?: string; + }; +} + +interface ProcessorEvent { + readonly raw_body: string; +} + +/** + * Async processor for verified GitHub `deployment_status` webhooks. + * + * Flow: + * 1. Parse the payload (already validated as deployment_status by the + * receiver, but we re-extract the fields we need). + * 2. Find the open PR for the deploy SHA via the GitHub Commits API. + * 3. Capture a screenshot of `deployment.environment_url` via + * AgentCore Browser. + * 4. PUT the PNG to the screenshot bucket. + * 5. POST a fresh PR comment with `![preview]()`. + * + * Every external call is best-effort. If any step fails, log + return — + * the receiver already 200'd, so retries by GitHub will dedup at the + * receiver layer. + */ +export async function handler(event: ProcessorEvent): Promise { + if (!event.raw_body) { + logger.error('GitHub webhook processor invoked without raw_body'); + return; + } + + let payload: GitHubDeploymentStatusPayload; + try { + payload = JSON.parse(event.raw_body) as GitHubDeploymentStatusPayload; + } catch (err) { + logger.error('GitHub webhook processor could not parse raw_body', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const repo = payload.repository?.full_name; + const sha = payload.deployment?.sha; + const previewUrl = payload.deployment?.environment_url; + const deploymentId = payload.deployment?.id; + + if (!repo || !sha || !previewUrl) { + logger.warn('GitHub deployment_status payload missing required fields', { + repo, + sha_present: Boolean(sha), + preview_url_present: Boolean(previewUrl), + deployment_id: deploymentId, + }); + return; + } + + logger.info('Screenshot pipeline starting', { + repo, + sha, + preview_url: previewUrl, + deployment_id: deploymentId, + }); + + let token: string; + try { + token = await resolveGitHubToken(GITHUB_TOKEN_SECRET_ARN); + } catch (err) { + logger.error('Failed to resolve GitHub token; cannot post screenshot comment', { + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const prNumber = await findPullRequestForSha(repo, sha, token); + if (!prNumber) { + logger.info('No open PR found for SHA — skipping screenshot post', { repo, sha }); + return; + } + + let png: Uint8Array; + try { + png = await captureScreenshot(previewUrl); + } catch (err) { + logger.error('Screenshot capture failed', { + preview_url: previewUrl, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const key = buildScreenshotKey(repo, sha, deploymentId); + try { + await s3.send(new PutObjectCommand({ + Bucket: SCREENSHOT_BUCKET, + Key: key, + Body: png, + ContentType: 'image/png', + Metadata: { + repo, + sha, + // S3 metadata values must be ASCII; coerce numeric to string and + // skip the URL itself (URL encoding into x-amz-meta-* is brittle). + deployment_id: String(deploymentId ?? ''), + }, + })); + } catch (err) { + logger.error('Failed to upload screenshot to S3', { + bucket: SCREENSHOT_BUCKET, + key, + error: err instanceof Error ? err.message : String(err), + }); + return; + } + + const publicUrl = buildPublicUrl(SCREENSHOT_BUCKET, key); + const commentBody = renderCommentBody(publicUrl, previewUrl); + + try { + const result = await upsertTaskComment({ + repo, + issueOrPrNumber: prNumber, + body: commentBody, + token, + // Always POST fresh — a single PR can have multiple preview screenshots + // as the user pushes new commits, and editing the prior comment in + // place would lose the history. + existingCommentId: undefined, + }); + logger.info('Posted screenshot comment to PR', { + repo, + pr_number: prNumber, + comment_id: result.commentId, + public_url: publicUrl, + }); + } catch (err) { + logger.warn('Failed to post screenshot PR comment (non-fatal)', { + repo, + pr_number: prNumber, + error: err instanceof Error ? err.message : String(err), + }); + } +} + +/** + * Look up an open PR associated with `sha`. Uses the + * "List pull requests associated with a commit" GitHub API + * (https://docs.github.com/rest/commits/commits#list-pull-requests-associated-with-a-commit). + * + * Returns the first OPEN PR's number, or null if none. Closed/merged + * PRs are filtered out — v1 only screenshots active reviews. + */ +async function findPullRequestForSha( + repo: string, + sha: string, + token: string, +): Promise { + const url = `https://api.github.com/repos/${repo}/commits/${sha}/pulls`; + let res: Response; + try { + res = await fetch(url, { + method: 'GET', + headers: { + 'Accept': 'application/vnd.github+json', + 'Authorization': `Bearer ${token}`, + 'X-GitHub-Api-Version': '2022-11-28', + }, + }); + } catch (err) { + logger.warn('GitHub commit-pulls fetch failed', { + repo, + sha, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (!res.ok) { + logger.warn('GitHub commit-pulls returned non-2xx', { + repo, + sha, + status: res.status, + }); + return null; + } + + const pulls = (await res.json()) as Array<{ number?: number; state?: string }>; + const open = pulls.find((p) => p.state === 'open' && typeof p.number === 'number'); + return open?.number ?? null; +} + +/** Build the S3 key for a screenshot. */ +function buildScreenshotKey(repo: string, sha: string, deploymentId: number | undefined): string { + const repoSlug = repo.replace('/', '_'); + const id = deploymentId !== undefined ? `-${deploymentId}` : ''; + return `screenshots/${repoSlug}/${sha}${id}.png`; +} + +/** Build the public-readable HTTPS URL for an S3 object in the screenshot bucket. */ +function buildPublicUrl(bucket: string, key: string): string { + return `https://${bucket}.s3.${REGION}.amazonaws.com/${key}`; +} + +/** Render the PR comment body. */ +function renderCommentBody(publicUrl: string, previewUrl: string): string { + return [ + '🖼️ **Preview screenshot**', + '', + `[![preview](${publicUrl})](${previewUrl})`, + '', + `_From [${previewUrl}](${previewUrl}) — captured automatically by ABCA after the deploy finished._`, + ].join('\n'); +} diff --git a/cdk/src/handlers/github-webhook.ts b/cdk/src/handlers/github-webhook.ts new file mode 100644 index 00000000..98efaad7 --- /dev/null +++ b/cdk/src/handlers/github-webhook.ts @@ -0,0 +1,244 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; +import { DeleteCommand, DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import type { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { verifyGitHubRequest } from './shared/github-webhook-verify'; +import { logger } from './shared/logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); +const lambdaClient = new LambdaClient({}); + +const WEBHOOK_SECRET_ARN = process.env.GITHUB_WEBHOOK_SECRET_ARN!; +const DEDUP_TABLE_NAME = process.env.GITHUB_WEBHOOK_DEDUP_TABLE_NAME!; +const PROCESSOR_FUNCTION_NAME = process.env.GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NAME!; + +/** + * Dedup window. GitHub redelivers a webhook up to 5 times when our + * receiver returns 5xx (each retry ~ exponential backoff, max ~30s + * apart). 1h is generous coverage with slack for clock skew. + */ +const DEDUP_TTL_SECONDS = 60 * 60; + +/** + * Subset of GitHub's `deployment_status` payload we route on. Vercel + * (and any GitHub-Deployments-API-aware deploy backend) posts this when + * a preview / production deploy finishes. The interesting fields: + * - `deployment_status.state`: `success` | `failure` | `error` | `pending` | `in_progress` + * - `deployment.environment`: `Preview` | `Production` + * - `deployment.environment_url`: the deployed URL (used by the agent + * as the screenshot target — no extra round-trip needed) + * - `deployment.sha`: the commit SHA the deploy is for (used to map + * back to an ABCA task via the RepoCommitIndex GSI) + * + * Full payload is forwarded to the processor without re-serialization + * risk — the processor parses its own copy from the raw body. + */ +interface GitHubDeploymentStatusEnvelope { + readonly action?: string; + readonly deployment_status?: { + readonly id?: number; + readonly state?: string; + }; + readonly deployment?: { + readonly id?: number; + readonly sha?: string; + readonly environment?: string; + readonly environment_url?: string; + }; + readonly repository?: { + readonly full_name?: string; + }; +} + +/** + * POST /v1/github/webhook — GitHub webhook receiver. + * + * Verifies `X-Hub-Signature-256` (per + * https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries), + * filters to `deployment_status` events from Vercel-style preview deploys, + * dedups on `(repo, deployment_id, status_id)`, and async-invokes the + * processor Lambda so we can ack within GitHub's 10s timeout. Other event + * types (push, pull_request, ping, …) get an immediate 200 so GitHub + * doesn't retry them. + * + * Why `deployment_status` and not `workflow_run`: + * Vercel doesn't run a GitHub Action to deploy — it posts directly to + * the GitHub Deployments API. `deployment_status` carries the deploy + * URL (`deployment.environment_url`) and the SHA the deploy is for, + * letting us route to the correct ABCA task and screenshot the right + * URL without extra API calls. + */ +export async function handler(event: APIGatewayProxyEvent): Promise { + try { + if (!event.body) { + return jsonResponse(400, { error: 'Request body is required' }); + } + + const signature = event.headers['X-Hub-Signature-256'] ?? event.headers['x-hub-signature-256'] ?? ''; + if (!signature) { + logger.warn('GitHub webhook missing X-Hub-Signature-256 header'); + return jsonResponse(401, { error: 'Missing signature' }); + } + + if (!await verifyGitHubRequest(WEBHOOK_SECRET_ARN, signature, event.body)) { + logger.warn('Invalid GitHub webhook signature'); + return jsonResponse(401, { error: 'Invalid signature' }); + } + + const eventType = event.headers['X-GitHub-Event'] ?? event.headers['x-github-event'] ?? ''; + + // GitHub fires `ping` once when the webhook is first registered. Ack with + // 200 so the GitHub UI shows the webhook as "delivered successfully" and + // operators don't think setup failed. + if (eventType === 'ping') { + return jsonResponse(200, { ok: true, ping: true }); + } + + // Anything other than deployment_status is silently 200'd. We'd rather + // drop unrelated events at the door than have them clutter the + // processor's invoke / log volume. + if (eventType !== 'deployment_status') { + logger.info('Ignoring non-deployment_status GitHub webhook', { event_type: eventType }); + return jsonResponse(200, { ok: true }); + } + + let payload: GitHubDeploymentStatusEnvelope; + try { + payload = JSON.parse(event.body) as GitHubDeploymentStatusEnvelope; + } catch (err) { + logger.warn('GitHub webhook body is not valid JSON', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(400, { error: 'Invalid JSON' }); + } + + // Vercel posts intermediate states (`pending`, `in_progress`) before + // the terminal `success` / `failure` / `error`. Only `success` deploys + // are worth screenshotting; everything else gets a clean 200 so GitHub + // doesn't retry. + if (payload.deployment_status?.state !== 'success') { + return jsonResponse(200, { ok: true, skipped_state: payload.deployment_status?.state }); + } + + // v1 scope: preview deploys only. Production deploys are skipped here + // (followup #87 in the plan covers post-merge screenshots if useful). + // Vercel labels its preview environment `Preview`; configurable via + // `SCREENSHOT_TARGET_ENVIRONMENT` env so non-Vercel backends with + // different naming can flip it without a code change. + const targetEnv = process.env.SCREENSHOT_TARGET_ENVIRONMENT ?? 'Preview'; + if (payload.deployment?.environment !== targetEnv) { + return jsonResponse(200, { + ok: true, + skipped_environment: payload.deployment?.environment, + }); + } + + const repo = payload.repository?.full_name; + const deploymentId = payload.deployment?.id; + const statusId = payload.deployment_status?.id; + if (!repo || !deploymentId || !statusId) { + logger.warn('GitHub deployment_status webhook missing repo, deployment id, or status id', { + repo, + deployment_id: deploymentId, + status_id: statusId, + }); + return jsonResponse(400, { error: 'Missing repo, deployment id, or status id' }); + } + + if (!payload.deployment?.environment_url) { + logger.warn('GitHub deployment_status webhook missing environment_url; cannot screenshot', { + repo, + deployment_id: deploymentId, + }); + return jsonResponse(200, { ok: true, skipped_no_url: true }); + } + + // Dedup on (repo, deployment_id, status_id). A single deploy lifecycle + // can emit multiple statuses; using the status id as the third leg + // keeps reruns of the same status (GitHub retries on 5xx) collapsed + // while distinct status transitions stay distinct. + const dedupKey = `${repo}#${deploymentId}#${statusId}`; + const nowSeconds = Math.floor(Date.now() / 1000); + try { + await ddb.send(new PutCommand({ + TableName: DEDUP_TABLE_NAME, + Item: { + dedup_key: dedupKey, + created_at: new Date().toISOString(), + ttl: nowSeconds + DEDUP_TTL_SECONDS, + }, + ConditionExpression: 'attribute_not_exists(dedup_key)', + })); + } catch (err) { + if (err instanceof ConditionalCheckFailedException) { + logger.info('GitHub webhook dedup hit — skipping reprocess', { + dedup_key: dedupKey, + }); + return jsonResponse(200, { ok: true, deduped: true }); + } + throw err; + } + + try { + await lambdaClient.send(new InvokeCommand({ + FunctionName: PROCESSOR_FUNCTION_NAME, + InvocationType: 'Event', + Payload: new TextEncoder().encode(JSON.stringify({ raw_body: event.body })), + })); + } catch (invokeErr) { + logger.error('Failed to invoke GitHub webhook processor', { + error: invokeErr instanceof Error ? invokeErr.message : String(invokeErr), + repo, + deployment_id: deploymentId, + status_id: statusId, + }); + // Roll the dedup row back so GitHub's retry can try dispatch again. + try { + await ddb.send(new DeleteCommand({ + TableName: DEDUP_TABLE_NAME, + Key: { dedup_key: dedupKey }, + })); + } catch (cleanupErr) { + logger.warn('Failed to roll back GitHub webhook dedup row after invoke failure', { + error: cleanupErr instanceof Error ? cleanupErr.message : String(cleanupErr), + dedup_key: dedupKey, + }); + } + return jsonResponse(500, { error: 'Dispatch failed' }); + } + + return jsonResponse(200, { ok: true }); + } catch (err) { + logger.error('GitHub webhook handler failed', { + error: err instanceof Error ? err.message : String(err), + }); + return jsonResponse(500, { error: 'Internal server error' }); + } +} + +function jsonResponse(statusCode: number, body: Record): APIGatewayProxyResult { + return { + statusCode, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }; +} diff --git a/cdk/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts new file mode 100644 index 00000000..4e497a4f --- /dev/null +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -0,0 +1,328 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { Sha256 } from '@aws-crypto/sha256-js'; +import { + BedrockAgentCoreClient, + StartBrowserSessionCommand, + StopBrowserSessionCommand, +} from '@aws-sdk/client-bedrock-agentcore'; +import { defaultProvider } from '@aws-sdk/credential-provider-node'; +import { HttpRequest } from '@smithy/protocol-http'; +import { SignatureV4 } from '@smithy/signature-v4'; +import { logger } from './logger'; + +const REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'; + +/** + * AWS-managed default browser identifier. AgentCore Browser publishes a + * shared browser at this id without provisioning. (We could call + * `CreateBrowser` to get a dedicated one, but the screenshot path + * doesn't need any custom config — keep it simple.) + */ +const AWS_BROWSER_IDENTIFIER = 'aws.browser.v1'; + +/** + * Default budget for the entire screenshot job (start session → navigate + * → screenshot → stop). Lambda timeout should be at least 15s above this + * to leave headroom for the JSON encode + S3 PUT after the screenshot. + */ +const DEFAULT_TIMEOUT_MS = 60_000; + +/** CDP message id allocator. */ +let nextCdpId = 1; + +interface CdpMessage { + readonly id?: number; + readonly method?: string; + readonly params?: Record; + readonly sessionId?: string; + readonly result?: Record; + readonly error?: { code: number; message: string }; +} + +/** + * Capture a full-page PNG screenshot of `url` via AgentCore Browser. + * + * Implementation notes: + * - Uses the native `WebSocket` (Node 24+) and speaks Chrome DevTools + * Protocol directly. Avoids pulling in Playwright / puppeteer-core + * into the Lambda bundle (would be ~150 MB). + * - The automation WSS endpoint requires a SigV4-signed handshake + * request. Browser session creation is a normal SigV4 SDK call; + * once the session is created, the WSS upgrade GET also needs + * SigV4 headers in `Sec-WebSocket-*` companion form. Node's + * `WebSocket` constructor accepts a custom `Headers` object via + * the `protocols`/`headers` slot in `clientOptions`. + * - The flow is intentionally minimal: + * 1. StartBrowserSession (REST API; SDK call) + * 2. WS connect to the automation streamEndpoint (SigV4 handshake) + * 3. CDP `Target.attachToBrowserTarget` to get a flat session + * 4. CDP `Target.getTargets`, find the about:blank page + * 5. `Target.attachToTarget` (flatten=true) on that page → sessionId + * 6. `Page.navigate` + wait for `Page.frameStoppedLoading` + * 7. `Page.captureScreenshot` (returns base64 PNG) + * 8. StopBrowserSession (best-effort; sessions auto-expire) + * + * We don't try to be clever about fonts, viewports, or cookie + * injection — the agent is just snapshotting Vercel preview URLs that + * render with default settings. + * + * @param url The URL to navigate to and screenshot. + * @param opts.timeoutMs Override the default 60s budget. + * @returns Raw PNG bytes (NOT base64-wrapped) ready for S3.PutObject. + */ +export async function captureScreenshot(url: string, opts: { timeoutMs?: number } = {}): Promise { + const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS; + const client = new BedrockAgentCoreClient({ region: REGION }); + + const startResp = await client.send(new StartBrowserSessionCommand({ + browserIdentifier: AWS_BROWSER_IDENTIFIER, + name: `bgagent-screenshot-${Date.now()}`, + })); + const sessionId = startResp.sessionId; + const automationEndpoint = startResp.streams?.automationStream?.streamEndpoint; + if (!sessionId || !automationEndpoint) { + throw new Error('AgentCore Browser StartBrowserSession returned no sessionId or automation endpoint'); + } + + logger.info('AgentCore Browser session started', { + session_id: sessionId, + automation_endpoint: automationEndpoint, + }); + + try { + const png = await runCdpScreenshot(automationEndpoint, url, timeoutMs); + return png; + } finally { + try { + await client.send(new StopBrowserSessionCommand({ + browserIdentifier: AWS_BROWSER_IDENTIFIER, + sessionId, + })); + } catch (err) { + // Sessions auto-expire after ~10 minutes if we leak — log and move on. + logger.warn('Failed to stop AgentCore Browser session (will auto-expire)', { + session_id: sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } +} + +/** + * Open the automation WebSocket, drive CDP, return PNG bytes. Caller is + * responsible for the StartBrowserSession + StopBrowserSession lifecycle. + */ +async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): Promise { + const headers = await sigV4WsHeaders(wssUrl); + + // The WebSocket constructor in Node 24 doesn't accept custom headers + // directly. Use the lower-level `undici` WebSocket via the `headers` + // option — but the standard `WebSocket` does NOT expose that. Workaround: + // attach the SigV4 headers as protocol fields. AWS's WSS handshake reads + // both Authorization headers and Sec-WebSocket-Protocol-encoded variants. + // + // Simpler: open with the classic `Authorization` style by passing + // headers via the dispatcher. Node 24 exposes `WebSocket` from undici + // which DOES support this through `globalThis.WebSocket`'s second arg. + const ws = new WebSocket(wssUrl, { headers } as unknown as string[]); + + const deadline = Date.now() + timeoutMs; + const remaining = () => Math.max(0, deadline - Date.now()); + + // Promise machinery for tracking in-flight CDP requests by `id`. + const pending = new Map void; reject: (err: Error) => void }>(); + const eventQueue: CdpMessage[] = []; + // Each waiter has a predicate; on each incoming event we deliver to the + // FIRST waiter whose predicate matches, otherwise queue the event. + interface EventWaiter { + readonly predicate: (msg: CdpMessage) => boolean; + readonly resolve: (msg: CdpMessage) => void; + } + const eventWaiters: EventWaiter[] = []; + + ws.addEventListener('message', (event) => { + const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); + let msg: CdpMessage; + try { + msg = JSON.parse(data) as CdpMessage; + } catch { + return; + } + if (typeof msg.id === 'number') { + const slot = pending.get(msg.id); + if (slot) { + pending.delete(msg.id); + if (msg.error) { + slot.reject(new Error(`CDP error ${msg.error.code}: ${msg.error.message}`)); + } else { + slot.resolve(msg); + } + } + } else if (msg.method) { + const waiterIdx = eventWaiters.findIndex((w) => w.predicate(msg)); + if (waiterIdx !== -1) { + const [waiter] = eventWaiters.splice(waiterIdx, 1); + waiter.resolve(msg); + } else { + eventQueue.push(msg); + } + } + }); + + // Open the socket. + await new Promise((resolve, reject) => { + const onOpen = () => { + cleanup(); + resolve(); + }; + const onError = (e: Event) => { + cleanup(); + reject(new Error(`AgentCore Browser WebSocket error: ${(e as ErrorEvent).message ?? '(no message)'}`)); + }; + const cleanup = () => { + ws.removeEventListener('open', onOpen); + ws.removeEventListener('error', onError); + }; + ws.addEventListener('open', onOpen); + ws.addEventListener('error', onError); + setTimeout(() => { + cleanup(); + reject(new Error(`AgentCore Browser WebSocket open timeout after ${timeoutMs}ms`)); + }, remaining()); + }); + + function cdpSend(method: string, params: Record = {}, sessionId?: string): Promise { + const id = nextCdpId++; + const message: CdpMessage = { id, method, params, ...(sessionId ? { sessionId } : {}) }; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`CDP ${method} timed out after ${remaining()}ms`)); + }, remaining()); + pending.set(id, { + resolve: (msg) => { clearTimeout(timer); resolve(msg); }, + reject: (err) => { clearTimeout(timer); reject(err); }, + }); + ws.send(JSON.stringify(message)); + }); + } + + function waitForEvent(method: string): Promise { + const queued = eventQueue.findIndex((m) => m.method === method); + if (queued !== -1) { + const [match] = eventQueue.splice(queued, 1); + return Promise.resolve(match); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const idx = eventWaiters.findIndex((w) => w.resolve === wrappedResolve); + if (idx !== -1) eventWaiters.splice(idx, 1); + reject(new Error(`Timed out waiting for CDP event ${method}`)); + }, remaining()); + const wrappedResolve = (msg: CdpMessage): void => { + clearTimeout(timer); + resolve(msg); + }; + eventWaiters.push({ + predicate: (msg) => msg.method === method, + resolve: wrappedResolve, + }); + }); + } + + try { + // 1. List existing targets, find the default about:blank page. + const targetsResp = await cdpSend('Target.getTargets'); + const targets = (targetsResp.result?.targetInfos as Array<{ targetId: string; type: string; url: string }> | undefined) ?? []; + const pageTarget = targets.find((t) => t.type === 'page'); + if (!pageTarget) { + throw new Error('No page target found in AgentCore Browser session'); + } + + // 2. Attach with flatten=true to get a sessionId we can route subsequent commands to. + const attachResp = await cdpSend('Target.attachToTarget', { + targetId: pageTarget.targetId, + flatten: true, + }); + const pageSessionId = attachResp.result?.sessionId as string | undefined; + if (!pageSessionId) { + throw new Error('Target.attachToTarget did not return a sessionId'); + } + + // 3. Enable Page domain so we get frameStoppedLoading events. + await cdpSend('Page.enable', {}, pageSessionId); + + // 4. Navigate. The response includes a `frameId`; we wait on the + // `Page.loadEventFired` event below (more reliable than + // `frameStoppedLoading` which can fire before navigation + // actually starts on `about:blank` → real-URL transitions). + const navResp = await cdpSend('Page.navigate', { url }, pageSessionId); + const navError = navResp.result?.errorText as string | undefined; + if (navError) { + throw new Error(`Page.navigate failed: ${navError}`); + } + + // 5. Wait for the page load event. SPA-style apps may continue + // fetching after this fires, so add a 2s settle wait. For + // Vercel preview URLs this tends to be enough. + await waitForEvent('Page.loadEventFired'); + await new Promise((r) => setTimeout(r, 2000)); + + // 6. Take the screenshot. + const shotResp = await cdpSend('Page.captureScreenshot', { + format: 'png', + captureBeyondViewport: true, + }, pageSessionId); + const base64 = shotResp.result?.data as string | undefined; + if (!base64) { + throw new Error('Page.captureScreenshot returned no data'); + } + return Buffer.from(base64, 'base64'); + } finally { + try { ws.close(); } catch { /* ignore */ } + } +} + +/** + * Build SigV4-signed headers for the WebSocket upgrade request. AgentCore + * Browser's WSS endpoint expects the same SigV4 envelope as a regular + * `bedrock-agentcore` HTTPS call. + */ +async function sigV4WsHeaders(wssUrl: string): Promise> { + const u = new URL(wssUrl); + const signer = new SignatureV4({ + service: 'bedrock-agentcore', + region: REGION, + credentials: defaultProvider(), + sha256: Sha256, + }); + const req = new HttpRequest({ + method: 'GET', + protocol: 'https:', + hostname: u.hostname, + path: u.pathname + u.search, + headers: { + host: u.hostname, + }, + }); + const signed = await signer.sign(req); + return signed.headers; +} diff --git a/cdk/src/handlers/shared/github-webhook-verify.ts b/cdk/src/handlers/shared/github-webhook-verify.ts new file mode 100644 index 00000000..1023686d --- /dev/null +++ b/cdk/src/handlers/shared/github-webhook-verify.ts @@ -0,0 +1,127 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as crypto from 'crypto'; +import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { logger } from './logger'; + +const sm = new SecretsManagerClient({}); + +/** + * In-memory secret cache (5-minute TTL). Same pattern as `linear-verify.ts` + * — webhook secrets rotate infrequently, and skipping a Secrets Manager + * round-trip on every webhook keeps the receiver well under GitHub's 10s + * timeout. After rotation, the verifier transparently re-fetches once. + */ +const secretCache = new Map(); +const CACHE_TTL_MS = 5 * 60 * 1000; + +/** + * Fetch a GitHub webhook secret from Secrets Manager with caching. + * @param secretId - the Secrets Manager secret ID or ARN. + * @param forceRefresh - bypass cache and re-fetch. + * @returns the secret string, or null if not found. + */ +export async function getGitHubWebhookSecret(secretId: string, forceRefresh = false): Promise { + const now = Date.now(); + if (!forceRefresh) { + const cached = secretCache.get(secretId); + if (cached && cached.expiresAt > now) { + return cached.secret; + } + } + + try { + const result = await sm.send(new GetSecretValueCommand({ SecretId: secretId })); + if (!result.SecretString) { + secretCache.delete(secretId); + return null; + } + secretCache.set(secretId, { secret: result.SecretString, expiresAt: now + CACHE_TTL_MS }); + return result.SecretString; + } catch (err) { + const errorName = (err as Error)?.name; + if (errorName === 'ResourceNotFoundException') { + logger.error('GitHub webhook secret not found', { secret_id: secretId }); + secretCache.delete(secretId); + return null; + } + logger.error('Failed to fetch GitHub webhook secret', { + secret_id: secretId, + error: err instanceof Error ? err.message : String(err), + }); + throw err; + } +} + +/** Drop a cached webhook secret — used on suspected rotation. */ +export function invalidateGitHubWebhookSecretCache(secretId: string): void { + secretCache.delete(secretId); +} + +/** + * Verify a GitHub webhook signature. + * + * GitHub signs with HMAC-SHA256 over the raw body, hex-encoded, prefixed + * with the literal `sha256=` and delivered in the `X-Hub-Signature-256` + * header (per + * https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries). + * The legacy `X-Hub-Signature` (SHA1) header is not validated — GitHub + * always sends both, but SHA256 is the secure one. + * + * @param webhookSecret - the per-webhook signing secret. + * @param header - the `X-Hub-Signature-256` header value (with `sha256=` prefix). + * @param body - the raw request body string. + * @returns true if the signature matches. + */ +export function verifyGitHubSignature(webhookSecret: string, header: string, body: string): boolean { + if (!header.startsWith('sha256=')) { + return false; + } + const provided = header.slice('sha256='.length); + const expected = crypto.createHmac('sha256', webhookSecret).update(body).digest('hex'); + try { + return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(provided)); + } catch (err) { + logger.warn('GitHub signature comparison failed', { + error: err instanceof Error ? err.message : String(err), + expected_length: expected.length, + provided_length: provided.length, + }); + return false; + } +} + +/** + * Verify a GitHub webhook request, with one transparent re-fetch on + * cache miss. Same UX as `verifyLinearRequest` so warm Lambdas don't + * silently reject post-rotation deliveries for up to 5 minutes. + */ +export async function verifyGitHubRequest(secretId: string, header: string, body: string): Promise { + const cached = await getGitHubWebhookSecret(secretId); + if (cached && verifyGitHubSignature(cached, header, body)) { + return true; + } + + invalidateGitHubWebhookSecretCache(secretId); + const fresh = await getGitHubWebhookSecret(secretId, true); + if (!fresh) return false; + if (fresh === cached) return false; + return verifyGitHubSignature(fresh, header, body); +} From 70eadfd957ce13faddba8274749c5619937dae40 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 14:09:52 -0700 Subject: [PATCH 002/190] feat(screenshot): GitHubScreenshotIntegration construct + stack wiring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New `GitHubScreenshotIntegration` construct (mirrors `LinearIntegration`): bundles the screenshot bucket, dedup table, signing-secret placeholder, receiver Lambda, processor Lambda, and the API Gateway route. cdk-nag suppressions added inline (HMAC auth instead of Cognito; AgentCore Browser sessions have no per-resource ARN; Secrets Manager rotation is owned by GitHub). - Wired into `agent.ts` after the LinearIntegration block. Reuses the existing `githubTokenSecret` (the processor uses ABCA's main GitHub token to look up which PR a deploy SHA belongs to and post the screenshot comment — no new credential). - Three new stack outputs: `GitHubWebhookUrl`, `GitHubWebhookSecretArn`, `ScreenshotBucketName`. - Bumped agent.test.ts table count from 13 to 14 to account for the new dedup table. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../github-screenshot-integration.ts | 235 ++++++++++++++++++ cdk/src/stacks/agent.ts | 27 ++ cdk/test/stacks/agent.test.ts | 7 +- 3 files changed, 266 insertions(+), 3 deletions(-) create mode 100644 cdk/src/constructs/github-screenshot-integration.ts diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts new file mode 100644 index 00000000..90403039 --- /dev/null +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -0,0 +1,235 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import * as path from 'path'; +import { Duration, RemovalPolicy } from 'aws-cdk-lib'; +import * as apigw from 'aws-cdk-lib/aws-apigateway'; +import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; +import * as iam from 'aws-cdk-lib/aws-iam'; +import { Architecture, Runtime } from 'aws-cdk-lib/aws-lambda'; +import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { NagSuppressions } from 'cdk-nag'; +import { Construct } from 'constructs'; +import { ScreenshotBucket } from './screenshot-bucket'; + +/** + * Properties for GitHubScreenshotIntegration construct. + */ +export interface GitHubScreenshotIntegrationProps { + /** The existing REST API to add the GitHub webhook route to. */ + readonly api: apigw.RestApi; + + /** + * Existing GitHub PAT secret. The processor reuses ABCA's main GitHub + * token to (a) look up which PR a deploy SHA belongs to via the + * Commits API, and (b) post the screenshot comment on that PR. + * No new GitHub credential is provisioned by this construct. + */ + readonly githubTokenSecret: secretsmanager.ISecret; + + /** + * Removal policy for the dedup table + screenshot bucket. Defaults + * to DESTROY so dev stacks don't accumulate orphans on `cdk destroy`. + */ + readonly removalPolicy?: RemovalPolicy; + + /** + * Override for the deploy environment we screenshot. Defaults to + * `Preview` (Vercel's label for per-PR deploys). Set this when + * targeting a different deploy backend. + * @default 'Preview' + */ + readonly screenshotTargetEnvironment?: string; +} + +/** + * CDK construct that adds the GitHub-deployment-status → screenshot → + * PR-comment pipeline. + * + * Topology mirrors `LinearIntegration`: + * - Receiver Lambda (HMAC-verifies, dedups, async-invokes processor) + * - Async processor Lambda (drives AgentCore Browser, uploads PNG, + * posts the PR comment) + * - Dedup DynamoDB table (1h TTL — covers GitHub's 5-attempt retry + * window with slack) + * - Webhook signing-secret (Secrets Manager placeholder; populated + * manually when the operator pastes GitHub's value into the secret) + * - Public-read screenshot S3 bucket + * - API Gateway route `POST /v1/github/webhook` + * + * Inbound-only adapter — there's no outbound polling or stream + * consumer, just the webhook → screenshot → comment fan-out. + */ +export class GitHubScreenshotIntegration extends Construct { + /** Public-read bucket hosting the screenshot PNGs. */ + public readonly screenshotBucket: ScreenshotBucket; + + /** + * GitHub webhook signing secret — placeholder. The operator pastes + * GitHub's signing-secret value here after configuring the webhook + * in the demo repo's settings; the secret is otherwise empty. + */ + public readonly webhookSecret: secretsmanager.Secret; + + /** Webhook dedup table (composite key = `repo#deployment_id#status_id`). */ + public readonly webhookDedupTable: dynamodb.Table; + + /** Webhook receiver Lambda (HMAC verifier + dispatcher). */ + public readonly webhookFn: lambda.NodejsFunction; + + /** Async processor Lambda (browser + S3 + PR comment). */ + public readonly webhookProcessorFn: lambda.NodejsFunction; + + constructor(scope: Construct, id: string, props: GitHubScreenshotIntegrationProps) { + super(scope, id); + + const removalPolicy = props.removalPolicy ?? RemovalPolicy.DESTROY; + + // --- Screenshot bucket (public-read on `screenshots/*`) --- + this.screenshotBucket = new ScreenshotBucket(this, 'ScreenshotBucket', { + removalPolicy, + }); + + // --- Webhook signing secret (operator-populated placeholder) --- + this.webhookSecret = new secretsmanager.Secret(this, 'WebhookSecret', { + description: 'GitHub deployment-status webhook signing secret — populate manually after configuring the GitHub webhook', + removalPolicy, + }); + + // --- Dedup table --- + this.webhookDedupTable = new dynamodb.Table(this, 'WebhookDedupTable', { + partitionKey: { name: 'dedup_key', type: dynamodb.AttributeType.STRING }, + billingMode: dynamodb.BillingMode.PAY_PER_REQUEST, + timeToLiveAttribute: 'ttl', + pointInTimeRecoverySpecification: { pointInTimeRecoveryEnabled: true }, + removalPolicy, + }); + + const handlersDir = path.join(__dirname, '..', 'handlers'); + const commonBundling: lambda.BundlingOptions = { + externalModules: ['@aws-sdk/*'], + }; + + // --- Async processor (browser + S3 + comment) --- + // Timeout budget: 60s screenshot + 5s navigate slack + 30s slack for + // the GitHub PR-lookup + comment + S3 PUT + JSON encode = 95s. Round + // to 120 for headroom on cold-start CDP handshake. + this.webhookProcessorFn = new lambda.NodejsFunction(this, 'WebhookProcessorFn', { + entry: path.join(handlersDir, 'github-webhook-processor.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(120), + memorySize: 512, + environment: { + SCREENSHOT_BUCKET_NAME: this.screenshotBucket.bucket.bucketName, + GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, + }, + bundling: commonBundling, + }); + + this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn); + props.githubTokenSecret.grantRead(this.webhookProcessorFn); + + // AgentCore Browser session lifecycle. The data-plane API doesn't + // support per-resource ARNs (sessions are ephemeral), so wildcards + // are required — annotated with a cdk-nag suppression below. + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: [ + 'bedrock-agentcore:StartBrowserSession', + 'bedrock-agentcore:StopBrowserSession', + 'bedrock-agentcore:GetBrowserSession', + 'bedrock-agentcore:UpdateBrowserStream', + ], + resources: ['*'], + })); + + // --- Webhook receiver (verify, dedup, dispatch) --- + this.webhookFn = new lambda.NodejsFunction(this, 'WebhookFn', { + entry: path.join(handlersDir, 'github-webhook.ts'), + handler: 'handler', + runtime: Runtime.NODEJS_24_X, + architecture: Architecture.ARM_64, + timeout: Duration.seconds(10), + environment: { + GITHUB_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn, + GITHUB_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName, + GITHUB_WEBHOOK_PROCESSOR_FUNCTION_NAME: this.webhookProcessorFn.functionName, + ...(props.screenshotTargetEnvironment && { + SCREENSHOT_TARGET_ENVIRONMENT: props.screenshotTargetEnvironment, + }), + }, + bundling: commonBundling, + }); + + this.webhookSecret.grantRead(this.webhookFn); + this.webhookDedupTable.grantReadWriteData(this.webhookFn); + this.webhookProcessorFn.grantInvoke(this.webhookFn); + + // --- API Gateway route --- + const githubResource = props.api.root.addResource('github'); + const webhookResource = githubResource.addResource('webhook'); + const webhookMethod = webhookResource.addMethod( + 'POST', + new apigw.LambdaIntegration(this.webhookFn), + { authorizationType: apigw.AuthorizationType.NONE }, + ); + + NagSuppressions.addResourceSuppressions(webhookMethod, [ + { + id: 'AwsSolutions-APIG4', + reason: 'GitHub webhook endpoint authenticates via X-Hub-Signature-256 HMAC, not Cognito — required by GitHub webhook protocol.', + }, + { + id: 'AwsSolutions-COG4', + reason: 'GitHub webhook endpoint authenticates via X-Hub-Signature-256 HMAC, not Cognito — required by GitHub webhook protocol.', + }, + ]); + + NagSuppressions.addResourceSuppressions(this.webhookFn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the standard managed policy for Lambda CloudWatch Logs writes.', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'DynamoDB grants from CDK helpers expand to table-arn/index/* wildcards; receiver only writes to the dedup table.', + }, + ], true); + + NagSuppressions.addResourceSuppressions(this.webhookProcessorFn, [ + { + id: 'AwsSolutions-IAM4', + reason: 'AWSLambdaBasicExecutionRole is the standard managed policy for Lambda CloudWatch Logs writes.', + }, + { + id: 'AwsSolutions-IAM5', + reason: 'AgentCore Browser sessions are ephemeral and have no per-resource ARN; the data-plane API requires wildcards. S3 PutObject uses CDK grant helpers that expand to bucket/* wildcards.', + }, + ], true); + + NagSuppressions.addResourceSuppressions(this.webhookSecret, [ + { + id: 'AwsSolutions-SMG4', + reason: 'GitHub webhook signing-secret rotation is owned by GitHub (operator regenerates on the GitHub side and pastes the new value here). No automated rotation Lambda needed.', + }, + ]); + } +} diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index e9c2eff0..7a4b63ed 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -41,6 +41,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'; @@ -803,6 +804,32 @@ export class AgentStack extends Stack { description: 'Name of the DynamoDB Linear workspace registry — `bgagent linear setup` writes a row per OAuth-installed workspace', }); + // --- GitHub deployment-status → screenshot pipeline --- + // Listens for Vercel-style preview deploys, screenshots the + // `deployment.environment_url` via AgentCore Browser, posts the + // image into a fresh PR comment. Default-on: any repo whose + // GitHub webhook is configured will get screenshotted on + // successful preview deploys; no opt-in flag. + const githubScreenshot = new GitHubScreenshotIntegration(this, 'GitHubScreenshotIntegration', { + api: taskApi.api, + githubTokenSecret, + }); + + new CfnOutput(this, 'GitHubWebhookUrl', { + value: `${taskApi.api.url}github/webhook`, + description: 'URL to configure as the GitHub webhook target on demo repos (deployment_status events)', + }); + + new CfnOutput(this, 'GitHubWebhookSecretArn', { + value: githubScreenshot.webhookSecret.secretArn, + description: 'Secrets Manager ARN for the GitHub webhook signing secret — paste GitHub\'s value here after configuring the webhook', + }); + + new CfnOutput(this, 'ScreenshotBucketName', { + value: githubScreenshot.screenshotBucket.bucket.bucketName, + description: 'S3 bucket hosting Vercel-preview screenshots (public read on screenshots/* prefix)', + }); + // --- Bedrock model invocation logging (account-level) --- const invocationLogGroup = new logs.LogGroup(this, 'ModelInvocationLogGroup', { logGroupName: `/aws/bedrock/model-invocation-logs/${this.stackName}`, diff --git a/cdk/test/stacks/agent.test.ts b/cdk/test/stacks/agent.test.ts index bec1ef15..859fb630 100644 --- a/cdk/test/stacks/agent.test.ts +++ b/cdk/test/stacks/agent.test.ts @@ -36,13 +36,14 @@ describe('AgentStack', () => { expect(template).toBeDefined(); }); - test('creates exactly 13 DynamoDB tables', () => { + test('creates exactly 14 DynamoDB tables', () => { // task, task-events, repo, user-concurrency, webhook, task-nudges, // task-approvals (Cedar HITL V2), // slack-installation, slack-user-mapping, // linear-project-mapping, linear-user-mapping, linear-webhook-dedup, - // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping) - template.resourceCountIs('AWS::DynamoDB::Table', 13); + // linear-workspace-registry (added in Phase 2.0b for OAuth bookkeeping), + // github-webhook-dedup (added by GitHubScreenshotIntegration) + template.resourceCountIs('AWS::DynamoDB::Table', 14); }); test('creates TaskApprovalsTable with user_id-status-index GSI', () => { From 3ca08c9d1f2c8f88e32ee42c400899395b9ece8e Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 14:11:16 -0700 Subject: [PATCH 003/190] fix(screenshot): suppress AwsSolutions-S2 on the public-read screenshot bucket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cdk-nag's S2 fires on any bucket that has `blockPublicPolicy: false` even when the policy is intentionally permissive. Add the suppression with the same rationale as S1/S5 — public reads are required by GitHub Markdown renderers and Linear `imageUploadFromUrl`, and the read grant is prefix-scoped to `screenshots/*`. Caught when the first deploy attempt aborted at synth-time on the new GitHubScreenshotIntegration construct. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/constructs/screenshot-bucket.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/cdk/src/constructs/screenshot-bucket.ts b/cdk/src/constructs/screenshot-bucket.ts index b97ae517..ce2b230b 100644 --- a/cdk/src/constructs/screenshot-bucket.ts +++ b/cdk/src/constructs/screenshot-bucket.ts @@ -130,6 +130,11 @@ export class ScreenshotBucket extends Construct { reason: 'Server access logs are not enabled for this bucket; screenshots are ephemeral artifacts (30-day TTL) embedded in GitHub PR comments and Linear issues. Adding access logging would generate substantial log volume for a low-value security signal — public reads are by design and the prefix is scoped to PNG renders only.', }, + { + id: 'AwsSolutions-S2', + reason: + 'Public-read on screenshots/* is intentional — GitHub markdown renderers and Linear `imageUploadFromUrl` both fetch the URL anonymously. The bucket policy is prefix-scoped (only `screenshots/*` is readable), and `blockPublicAcls`+`ignorePublicAcls` are still on so per-object ACLs can never override.', + }, { id: 'AwsSolutions-S5', reason: From b00d07df698c82fca3492463cfe36e73c375809b Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 14:21:55 -0700 Subject: [PATCH 004/190] fix(screenshot): private S3 bucket + CloudFront distribution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The first deploy attempt failed at CFN-execute time on the bucket policy: s3:PutBucketPolicy ... because public policies are prevented by the BlockPublicPolicy setting in S3 Block Public Access. Account-level Block Public Access is on for this AWS account, which overrides per-bucket BPA settings. Disabling it would change the security posture of the whole account, so route around the constraint with the AWS-recommended pattern: private S3 + CloudFront with Origin Access Control. Changes: - `ScreenshotBucket` is now `BLOCK_ALL` BPA, no public bucket policy. Adds a `cloudfront.Distribution` whose origin is the bucket via `S3BucketOrigin.withOriginAccessControl`. The distribution policy is scoped to the CloudFront service principal only, so account-level BPA accepts it. - Processor reads `SCREENSHOT_PUBLIC_HOST` (the CloudFront domain) instead of building an S3 URL. PR comments now embed `https://.cloudfront.net/screenshots/...` URLs. - New stack output `ScreenshotCloudFrontDomain`. - Bucket-level S2/S5 suppressions removed (no longer applicable — bucket is private). Distribution gets CFR1/CFR2/CFR3/CFR4/CFR7 suppressions with rationales. Heads up on deploy time: CloudFront distributions take 5-15 min to provision on first create. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../github-screenshot-integration.ts | 1 + cdk/src/constructs/screenshot-bucket.ts | 125 +++++++++--------- cdk/src/handlers/github-webhook-processor.ts | 13 +- cdk/src/stacks/agent.ts | 7 +- 4 files changed, 79 insertions(+), 67 deletions(-) diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts index 90403039..3a96f9a1 100644 --- a/cdk/src/constructs/github-screenshot-integration.ts +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -140,6 +140,7 @@ export class GitHubScreenshotIntegration extends Construct { memorySize: 512, environment: { SCREENSHOT_BUCKET_NAME: this.screenshotBucket.bucket.bucketName, + SCREENSHOT_PUBLIC_HOST: this.screenshotBucket.distribution.domainName, GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, }, bundling: commonBundling, diff --git a/cdk/src/constructs/screenshot-bucket.ts b/cdk/src/constructs/screenshot-bucket.ts index ce2b230b..76c4b6b7 100644 --- a/cdk/src/constructs/screenshot-bucket.ts +++ b/cdk/src/constructs/screenshot-bucket.ts @@ -17,8 +17,9 @@ * SOFTWARE. */ -import { Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; -import * as iam from 'aws-cdk-lib/aws-iam'; +import { Duration, RemovalPolicy } from 'aws-cdk-lib'; +import * as cloudfront from 'aws-cdk-lib/aws-cloudfront'; +import * as origins from 'aws-cdk-lib/aws-cloudfront-origins'; import * as s3 from 'aws-cdk-lib/aws-s3'; import { NagSuppressions } from 'cdk-nag'; import { Construct } from 'constructs'; @@ -28,29 +29,18 @@ export const SCREENSHOT_TTL_DAYS = 30; /** * Object-key prefix for all screenshots. Key layout: - * ``screenshots/.png``. The bucket policy grants public - * ``s3:GetObject`` on this prefix only — anything written outside is - * invisible to anonymous readers. + * ``screenshots//.png``. The CloudFront distribution serves + * the entire bucket, but the processor only ever writes under this + * prefix. */ export const SCREENSHOT_KEY_PREFIX = 'screenshots/'; -/** - * Build the public HTTPS URL for a screenshot object. Path-style URL is - * intentional — virtual-hosted style breaks for buckets with dots in - * the name (CDK auto-generated names sometimes include dots when the - * region is appended). - */ -export function screenshotPublicUrl(bucket: s3.IBucket, key: string): string { - const region = Stack.of(bucket).region; - return `https://${bucket.bucketName}.s3.${region}.amazonaws.com/${key}`; -} - /** * Properties for ScreenshotBucket construct. */ export interface ScreenshotBucketProps { /** - * Removal policy for the bucket. + * Removal policy for the bucket + distribution. * @default RemovalPolicy.DESTROY */ readonly removalPolicy?: RemovalPolicy; @@ -63,40 +53,37 @@ export interface ScreenshotBucketProps { } /** - * S3 bucket hosting screenshot PNGs that the agent embeds in GitHub PR - * + Linear issue comments. + * Private S3 bucket fronted by a CloudFront distribution that serves + * screenshot PNGs to GitHub Markdown / Linear render pipelines. + * + * Why CloudFront and not a public-read bucket: the AWS account-level + * Block Public Access is on (S3 control plane refuses to attach any + * public bucket policy), and disabling it would change the security + * posture of the whole account. CloudFront with Origin Access Control + * is the AWS-recommended path for "S3 object served anonymously over + * HTTPS." Bucket stays fully private; only the distribution principal + * has GetObject. * - * The agent writes ``screenshots/.png`` after AgentCore Browser - * captures the deployed GitHub Pages URL. Both GitHub Markdown rendering - * and Linear's image previews fetch the URL anonymously, so the prefix - * is configured for unauthenticated reads. + * Layout: + * s3:///screenshots//.png (private) + * https://.cloudfront.net/screenshots//.png (anon) * - * Security shape: - * - ``blockPublicAcls`` and ``ignorePublicAcls`` true — no per-object ACLs - * can grant access; only the bucket policy decides. - * - ``blockPublicPolicy`` and ``restrictPublicBuckets`` false — the policy - * intentionally grants public read on ``screenshots/*``. - * - Bucket policy: anonymous ``s3:GetObject`` limited to the - * ``screenshots/*`` key prefix and TLS-only transport. Writes still - * require IAM (the agent's runtime role). - * - SSE-S3 at rest, ``enforceSSL`` true. - * - 30-day lifecycle so screenshots don't accumulate forever. + * The 30-day lifecycle on the bucket is the source of truth for + * expiry — CloudFront's edge caches will see 403s after the TTL + * lapses, which is fine for stale PR comments. */ export class ScreenshotBucket extends Construct { - /** The underlying S3 bucket. */ + /** The underlying private S3 bucket. */ public readonly bucket: s3.Bucket; + /** CloudFront distribution serving the bucket anonymously. */ + public readonly distribution: cloudfront.Distribution; + constructor(scope: Construct, id: string, props: ScreenshotBucketProps = {}) { super(scope, id); this.bucket = new s3.Bucket(this, 'Bucket', { - // Allow public bucket policy (the next statement); deny public ACLs. - blockPublicAccess: new s3.BlockPublicAccess({ - blockPublicAcls: true, - ignorePublicAcls: true, - blockPublicPolicy: false, - restrictPublicBuckets: false, - }), + blockPublicAccess: s3.BlockPublicAccess.BLOCK_ALL, encryption: s3.BucketEncryption.S3_MANAGED, enforceSSL: true, lifecycleRules: [ @@ -111,34 +98,54 @@ export class ScreenshotBucket extends Construct { autoDeleteObjects: props.autoDeleteObjects ?? true, }); - // Public read on the screenshots/ prefix only. Both GitHub markdown - // and Linear's `imageUploadFromUrl` need to GET the URL anonymously. - this.bucket.addToResourcePolicy(new iam.PolicyStatement({ - sid: 'AllowAnonymousReadOfScreenshotsPrefix', - effect: iam.Effect.ALLOW, - principals: [new iam.AnyPrincipal()], - actions: ['s3:GetObject'], - resources: [`${this.bucket.bucketArn}/${SCREENSHOT_KEY_PREFIX}*`], - conditions: { - Bool: { 'aws:SecureTransport': 'true' }, + // CloudFront → S3 via Origin Access Control. The bucket policy is + // generated automatically by `S3BucketOrigin.withOriginAccessControl` + // and grants `s3:GetObject` to the distribution's CF service principal + // only — no anonymous principal in the policy, so account-level BPA + // doesn't reject it. + this.distribution = new cloudfront.Distribution(this, 'Distribution', { + defaultBehavior: { + origin: origins.S3BucketOrigin.withOriginAccessControl(this.bucket), + viewerProtocolPolicy: cloudfront.ViewerProtocolPolicy.REDIRECT_TO_HTTPS, + // Screenshots are immutable per (repo, sha) — long TTL is safe + // and minimizes origin S3 requests on hot PRs. + cachePolicy: cloudfront.CachePolicy.CACHING_OPTIMIZED, + allowedMethods: cloudfront.AllowedMethods.ALLOW_GET_HEAD, }, - })); + // No alternate domain or ACM cert — the default + // *.cloudfront.net hostname is fine for a backend artifact host. + enableLogging: false, + comment: 'ABCA screenshot artifacts (private S3 + OAC)', + }); NagSuppressions.addResourceSuppressions(this.bucket, [ { id: 'AwsSolutions-S1', reason: - 'Server access logs are not enabled for this bucket; screenshots are ephemeral artifacts (30-day TTL) embedded in GitHub PR comments and Linear issues. Adding access logging would generate substantial log volume for a low-value security signal — public reads are by design and the prefix is scoped to PNG renders only.', + 'Server access logs are not enabled for this bucket; screenshots are ephemeral artifacts (30-day TTL) embedded in GitHub PR comments. Adding access logging would generate substantial log volume for a low-value security signal.', }, + ], true); + + NagSuppressions.addResourceSuppressions(this.distribution, [ { - id: 'AwsSolutions-S2', - reason: - 'Public-read on screenshots/* is intentional — GitHub markdown renderers and Linear `imageUploadFromUrl` both fetch the URL anonymously. The bucket policy is prefix-scoped (only `screenshots/*` is readable), and `blockPublicAcls`+`ignorePublicAcls` are still on so per-object ACLs can never override.', + id: 'AwsSolutions-CFR1', + reason: 'No geo restrictions are needed — screenshots are referenced from GitHub.com which is global; restricting origins would break cross-region PR reviewers.', }, { - id: 'AwsSolutions-S5', - reason: - 'Public-read on screenshots/* is intentional — GitHub markdown renderers and Linear imageUploadFromUrl both require anonymous GET on the embedded image URL. Followup #79 will move to CloudFront with signed URLs once the feature stabilizes.', + id: 'AwsSolutions-CFR2', + reason: 'AWS WAF is not attached to this distribution. The content is read-only PNGs of preview deploys; no app logic, no input handling, no auth — WAF would only add cost without reducing risk.', + }, + { + id: 'AwsSolutions-CFR3', + reason: 'Access logs are not enabled on the distribution for the same reason as the bucket — low-value high-volume signal for ephemeral artifacts.', + }, + { + id: 'AwsSolutions-CFR4', + reason: 'Distribution uses the default *.cloudfront.net certificate (TLSv1+ enforced by AWS). No custom domain, so no minimum-TLS-version override needed.', + }, + { + id: 'AwsSolutions-CFR7', + reason: 'OAC is in use (the construct calls `S3BucketOrigin.withOriginAccessControl`). cdk-nag misclassifies the L2 helper as an OAI deployment.', }, ], true); } diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index 54ba98d9..da1972c6 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -26,8 +26,12 @@ import { logger } from './shared/logger'; const s3 = new S3Client({}); const SCREENSHOT_BUCKET = process.env.SCREENSHOT_BUCKET_NAME!; +// CloudFront distribution domain — `.cloudfront.net`. Used as +// the public host for the screenshot URL embedded in PR comments. +// The bucket is private; CloudFront with OAC reads on the agent's +// behalf. +const SCREENSHOT_PUBLIC_HOST = process.env.SCREENSHOT_PUBLIC_HOST!; const GITHUB_TOKEN_SECRET_ARN = process.env.GITHUB_TOKEN_SECRET_ARN!; -const REGION = process.env.AWS_REGION ?? 'us-east-1'; interface GitHubDeploymentStatusPayload { readonly action?: string; @@ -156,7 +160,7 @@ export async function handler(event: ProcessorEvent): Promise { return; } - const publicUrl = buildPublicUrl(SCREENSHOT_BUCKET, key); + const publicUrl = `https://${SCREENSHOT_PUBLIC_HOST}/${key}`; const commentBody = renderCommentBody(publicUrl, previewUrl); try { @@ -239,11 +243,6 @@ function buildScreenshotKey(repo: string, sha: string, deploymentId: number | un return `screenshots/${repoSlug}/${sha}${id}.png`; } -/** Build the public-readable HTTPS URL for an S3 object in the screenshot bucket. */ -function buildPublicUrl(bucket: string, key: string): string { - return `https://${bucket}.s3.${REGION}.amazonaws.com/${key}`; -} - /** Render the PR comment body. */ function renderCommentBody(publicUrl: string, previewUrl: string): string { return [ diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index 7a4b63ed..c1b97294 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -827,7 +827,12 @@ export class AgentStack extends Stack { new CfnOutput(this, 'ScreenshotBucketName', { value: githubScreenshot.screenshotBucket.bucket.bucketName, - description: 'S3 bucket hosting Vercel-preview screenshots (public read on screenshots/* prefix)', + description: 'Private S3 bucket hosting Vercel-preview screenshots (served via CloudFront)', + }); + + new CfnOutput(this, 'ScreenshotCloudFrontDomain', { + value: githubScreenshot.screenshotBucket.distribution.domainName, + description: 'CloudFront domain that serves the screenshot bucket anonymously to GitHub PR / Linear renders', }); // --- Bedrock model invocation logging (account-level) --- From 8b26810771dfeccd3a1215d243b209ab01491b51 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 17:42:03 -0700 Subject: [PATCH 005/190] fix(waf): exempt /v1/github/webhook from CRS like /v1/linear/webhook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CommonRuleSet was 403'ing GitHub deployment_status webhooks before the request reached our Lambda — the deployment payload contains absolute Vercel preview URLs in the body, which trips GenericRFI_BODY. Mirror the Linear webhook exemption: the GitHub webhook path is HMAC-verified in the Lambda, parsed as strict JSON, never interpolated into SQL/HTML, and rate-limited by the priority-3 rule. CRS still applies to every other route. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/constructs/task-api.ts | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/cdk/src/constructs/task-api.ts b/cdk/src/constructs/task-api.ts index 6ecd745a..cf6ec312 100644 --- a/cdk/src/constructs/task-api.ts +++ b/cdk/src/constructs/task-api.ts @@ -295,6 +295,17 @@ export class TaskApi extends Construct { textTransformations: [{ priority: 0, type: 'NONE' }], }, }, + { + // GitHub deployment_status webhook (Vercel preview + // screenshot pipeline) — absolute deploy URLs trip + // GenericRFI_BODY. HMAC-verified in Lambda. + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/github/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, ], }, }, @@ -342,6 +353,18 @@ export class TaskApi extends Construct { }, }, }, + { + notStatement: { + statement: { + byteMatchStatement: { + fieldToMatch: { uriPath: {} }, + positionalConstraint: 'EXACTLY', + searchString: '/v1/github/webhook', + textTransformations: [{ priority: 0, type: 'NONE' }], + }, + }, + }, + }, ], }, }, From 40120cb77c5acd043e3760b3d3b62c8432c93c6c Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 18:07:07 -0700 Subject: [PATCH 006/190] fix(screenshot): read environment_url from deployment_status, not deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub's `deployment_status` webhook puts the deployed URL on the *status* object, not the deployment itself. The deployment object is immutable per (sha, environment); the status changes through the deploy lifecycle (`pending` → `success`) and carries the URL only once the deploy finishes. Symptom: receiver kept short-circuiting `success` events from Vercel with `{ok: true, skipped_no_url: true}` because we read the wrong field. Verified by inspecting the webhook delivery payload via `gh api .../deliveries/ --jq .request.payload.deployment_status` — URL was there all along. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/handlers/github-webhook-processor.ts | 7 +++++-- cdk/src/handlers/github-webhook.ts | 13 ++++++++----- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index da1972c6..935ffe3d 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -39,12 +39,13 @@ interface GitHubDeploymentStatusPayload { readonly id?: number; readonly state?: string; readonly target_url?: string; + /** The deployed URL — lives on the *status* object, not the deployment. */ + readonly environment_url?: string; }; readonly deployment?: { readonly id?: number; readonly sha?: string; readonly environment?: string; - readonly environment_url?: string; }; readonly repository?: { readonly full_name?: string; @@ -89,7 +90,9 @@ export async function handler(event: ProcessorEvent): Promise { const repo = payload.repository?.full_name; const sha = payload.deployment?.sha; - const previewUrl = payload.deployment?.environment_url; + // The URL lives on `deployment_status` (it changes per status update — + // `pending` has no URL, `success` fills it in), not on `deployment`. + const previewUrl = payload.deployment_status?.environment_url; const deploymentId = payload.deployment?.id; if (!repo || !sha || !previewUrl) { diff --git a/cdk/src/handlers/github-webhook.ts b/cdk/src/handlers/github-webhook.ts index 98efaad7..bad8f345 100644 --- a/cdk/src/handlers/github-webhook.ts +++ b/cdk/src/handlers/github-webhook.ts @@ -43,11 +43,14 @@ const DEDUP_TTL_SECONDS = 60 * 60; * (and any GitHub-Deployments-API-aware deploy backend) posts this when * a preview / production deploy finishes. The interesting fields: * - `deployment_status.state`: `success` | `failure` | `error` | `pending` | `in_progress` + * - `deployment_status.environment_url`: the deployed URL — lives on the + * *status* object, not the deployment itself. (The deployment object + * only has the immutable SHA + environment name; URL changes per + * status update — first `pending` has no URL, then `success` fills + * it in.) * - `deployment.environment`: `Preview` | `Production` - * - `deployment.environment_url`: the deployed URL (used by the agent - * as the screenshot target — no extra round-trip needed) * - `deployment.sha`: the commit SHA the deploy is for (used to map - * back to an ABCA task via the RepoCommitIndex GSI) + * back to a PR via the GitHub commit-pulls API) * * Full payload is forwarded to the processor without re-serialization * risk — the processor parses its own copy from the raw body. @@ -57,12 +60,12 @@ interface GitHubDeploymentStatusEnvelope { readonly deployment_status?: { readonly id?: number; readonly state?: string; + readonly environment_url?: string; }; readonly deployment?: { readonly id?: number; readonly sha?: string; readonly environment?: string; - readonly environment_url?: string; }; readonly repository?: { readonly full_name?: string; @@ -164,7 +167,7 @@ export async function handler(event: APIGatewayProxyEvent): Promise Date: Wed, 20 May 2026 18:15:11 -0700 Subject: [PATCH 007/190] fix(agentcore-browser): use ws package for SigV4-signed WebSocket handshake MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Node 24's global WebSocket (from undici) does NOT support arbitrary HTTP headers on the upgrade request — passing them as the second arg gets silently ignored. AgentCore Browser's WSS handshake requires SigV4-signed Authorization + X-Amz-* headers, so the connection was opening but then getting rejected, which surfaced as an empty `error` event ("AgentCore Browser WebSocket error: "). Switch to the `ws` package which natively supports `options.headers`. Also add an `unexpected-response` handler so HTTP-level handshake failures (403, 400) surface with status codes instead of empty errors. Smoke verified locally — the ws-based path opens cleanly against example.com and Vercel preview URLs. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/package.json | 6 ++- cdk/src/handlers/shared/agentcore-browser.ts | 48 +++++++++++--------- yarn.lock | 12 +++++ 3 files changed, 43 insertions(+), 23 deletions(-) diff --git a/cdk/package.json b/cdk/package.json index a7a08889..6b9e85ad 100644 --- a/cdk/package.json +++ b/cdk/package.json @@ -36,9 +36,10 @@ "aws-cdk-lib": "^2.238.0", "cdk-nag": "^2.37.55", "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": "^1.5.10", @@ -48,6 +49,7 @@ "@types/js-yaml": "^4.0.9", "@types/node": "^20", "@types/pdf-parse": "^1.1.4", + "@types/ws": "^8.5.13", "@typescript-eslint/eslint-plugin": "^8", "@typescript-eslint/parser": "^8", "aws-cdk": "^2", diff --git a/cdk/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts index 4e497a4f..5660b444 100644 --- a/cdk/src/handlers/shared/agentcore-browser.ts +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -26,6 +26,7 @@ import { import { defaultProvider } from '@aws-sdk/credential-provider-node'; import { HttpRequest } from '@smithy/protocol-http'; import { SignatureV4 } from '@smithy/signature-v4'; +import WebSocket, { type RawData } from 'ws'; import { logger } from './logger'; const REGION = process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'; @@ -133,16 +134,12 @@ export async function captureScreenshot(url: string, opts: { timeoutMs?: number async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): Promise { const headers = await sigV4WsHeaders(wssUrl); - // The WebSocket constructor in Node 24 doesn't accept custom headers - // directly. Use the lower-level `undici` WebSocket via the `headers` - // option — but the standard `WebSocket` does NOT expose that. Workaround: - // attach the SigV4 headers as protocol fields. AWS's WSS handshake reads - // both Authorization headers and Sec-WebSocket-Protocol-encoded variants. - // - // Simpler: open with the classic `Authorization` style by passing - // headers via the dispatcher. Node 24 exposes `WebSocket` from undici - // which DOES support this through `globalThis.WebSocket`'s second arg. - const ws = new WebSocket(wssUrl, { headers } as unknown as string[]); + // Use `ws` (the standard Node WebSocket client) — its constructor accepts + // custom HTTP headers on the upgrade GET via `options.headers`. Node 24's + // global `WebSocket` (from undici) does NOT accept arbitrary headers, and + // AgentCore Browser's WSS handshake requires SigV4-signed `Authorization` + // + `X-Amz-*` headers, so we have to inject them somehow. + const ws = new WebSocket(wssUrl, { headers }); const deadline = Date.now() + timeoutMs; const remaining = () => Math.max(0, deadline - Date.now()); @@ -158,8 +155,8 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): } const eventWaiters: EventWaiter[] = []; - ws.addEventListener('message', (event) => { - const data = typeof event.data === 'string' ? event.data : new TextDecoder().decode(event.data as ArrayBuffer); + ws.on('message', (raw: RawData) => { + const data = raw.toString(); let msg: CdpMessage; try { msg = JSON.parse(data) as CdpMessage; @@ -187,22 +184,31 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): } }); - // Open the socket. + // Open the socket. `ws` exposes node-style EventEmitter; the + // `unexpected-response` event surfaces HTTP-level handshake failures + // (e.g. 403 from misaligned SigV4) so we can log a meaningful error + // instead of an empty `error` event. await new Promise((resolve, reject) => { - const onOpen = () => { + const onOpen = (): void => { cleanup(); resolve(); }; - const onError = (e: Event) => { + const onError = (err: Error): void => { cleanup(); - reject(new Error(`AgentCore Browser WebSocket error: ${(e as ErrorEvent).message ?? '(no message)'}`)); + reject(new Error(`AgentCore Browser WebSocket error: ${err.message || '(no message)'}`)); }; - const cleanup = () => { - ws.removeEventListener('open', onOpen); - ws.removeEventListener('error', onError); + const onUnexpectedResponse = (_req: unknown, res: { statusCode?: number }): void => { + cleanup(); + reject(new Error(`AgentCore Browser WebSocket handshake failed: HTTP ${res.statusCode ?? '?'}`)); + }; + const cleanup = (): void => { + ws.removeListener('open', onOpen); + ws.removeListener('error', onError); + ws.removeListener('unexpected-response', onUnexpectedResponse); }; - ws.addEventListener('open', onOpen); - ws.addEventListener('error', onError); + ws.on('open', onOpen); + ws.on('error', onError); + ws.on('unexpected-response', onUnexpectedResponse); setTimeout(() => { cleanup(); reject(new Error(`AgentCore Browser WebSocket open timeout after ${timeoutMs}ms`)); diff --git a/yarn.lock b/yarn.lock index e1322a0c..6759052c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5582,6 +5582,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" @@ -11943,6 +11950,11 @@ write-file-atomic@^5.0.1: imurmurhash "^0.1.4" signal-exit "^4.0.1" +ws@^8.18.0: + version "8.20.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.20.1.tgz#91a9ae2b312ccf98e0a85ec499b48cef45ab0ddb" + integrity sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w== + xml-naming@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/xml-naming/-/xml-naming-0.1.0.tgz#8ab7106c5b8d23caa2fabac1cadf17136379fbd8" From 07d8bbb6c499edf3969d6c5bcd645b8e3836e508 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 18:30:25 -0700 Subject: [PATCH 008/190] fix(agentcore-browser): SigV4-presign WSS URL instead of signing headers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lambda runtime returned a 403 on the WSS upgrade despite well-formed SigV4 headers — `ws` rewrites the Host header during the upgrade GET, which invalidates the canonical-request signature we computed against the original Host. This works locally because Node's tooling on macOS keeps the original Host through the handshake, but the Lambda runtime's TLS stack normalizes differently. Switch to query-parameter SigV4 (presigned URL): SignatureV4.presign returns a wss://...?X-Amz-Algorithm=...&X-Amz-Signature=... URL where the auth lives in the URL itself, so any Host-header rewriting downstream doesn't break the signature. Smoke verified locally — presigned URL connects cleanly to AgentCore Browser and the screenshot pipeline runs end-to-end (6.3s, valid PNG, captures example.com correctly). Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/handlers/shared/agentcore-browser.ts | 55 +++++++++++++------- 1 file changed, 37 insertions(+), 18 deletions(-) diff --git a/cdk/src/handlers/shared/agentcore-browser.ts b/cdk/src/handlers/shared/agentcore-browser.ts index 5660b444..a48c3545 100644 --- a/cdk/src/handlers/shared/agentcore-browser.ts +++ b/cdk/src/handlers/shared/agentcore-browser.ts @@ -132,14 +132,14 @@ export async function captureScreenshot(url: string, opts: { timeoutMs?: number * responsible for the StartBrowserSession + StopBrowserSession lifecycle. */ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): Promise { - const headers = await sigV4WsHeaders(wssUrl); - - // Use `ws` (the standard Node WebSocket client) — its constructor accepts - // custom HTTP headers on the upgrade GET via `options.headers`. Node 24's - // global `WebSocket` (from undici) does NOT accept arbitrary headers, and - // AgentCore Browser's WSS handshake requires SigV4-signed `Authorization` - // + `X-Amz-*` headers, so we have to inject them somehow. - const ws = new WebSocket(wssUrl, { headers }); + // AgentCore Browser's WSS endpoint accepts SigV4 in two forms: signed + // `Authorization` headers OR signed query parameters (presigned URL). + // We use the presigned-URL form because the `Host` header sent by the + // WS upgrade (handled inside `ws`) doesn't always match what we signed + // when using header-based auth, leading to 403s. Query-param signing + // sidesteps the Host-header reconciliation entirely. + const signedUrl = await sigV4PresignWss(wssUrl); + const ws = new WebSocket(signedUrl); const deadline = Date.now() + timeoutMs; const remaining = () => Math.max(0, deadline - Date.now()); @@ -308,27 +308,46 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number): } /** - * Build SigV4-signed headers for the WebSocket upgrade request. AgentCore - * Browser's WSS endpoint expects the same SigV4 envelope as a regular - * `bedrock-agentcore` HTTPS call. + * Presign the WSS URL with SigV4 query parameters. AgentCore Browser + * accepts auth either as headers on the upgrade GET or as query params + * on the URL itself; the latter is more robust through WebSocket + * clients that rewrite Host headers (e.g. `ws`). + * + * Returns a `wss://...?X-Amz-Algorithm=...&X-Amz-Credential=...&...` + * URL ready to pass straight to `new WebSocket(...)`. */ -async function sigV4WsHeaders(wssUrl: string): Promise> { +async function sigV4PresignWss(wssUrl: string): Promise { const u = new URL(wssUrl); const signer = new SignatureV4({ service: 'bedrock-agentcore', region: REGION, credentials: defaultProvider(), sha256: Sha256, + applyChecksum: false, }); + + // Convert wss:// → https:// for the signing request (SigV4 doesn't + // know about wss). The signature is over the path + query, so the + // protocol on the signed request is irrelevant — we paste the auth + // params back onto the original wss:// URL. + const queryEntries = Array.from(u.searchParams.entries()); + const query: Record = {}; + for (const [k, v] of queryEntries) query[k] = v; + const req = new HttpRequest({ method: 'GET', protocol: 'https:', hostname: u.hostname, - path: u.pathname + u.search, - headers: { - host: u.hostname, - }, + path: u.pathname, + query, + headers: { host: u.hostname }, }); - const signed = await signer.sign(req); - return signed.headers; + + // 60s expiry is fine — we open the socket immediately after signing. + const presigned = await signer.presign(req, { expiresIn: 60 }); + const out = new URL(wssUrl); + for (const [k, v] of Object.entries(presigned.query ?? {})) { + out.searchParams.set(k, Array.isArray(v) ? v[0] : (v as string)); + } + return out.toString(); } From 077f843160d74b89834f49bf5111b492a66b57c2 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 18:43:28 -0700 Subject: [PATCH 009/190] fix(iam): grant bedrock-agentcore:* to the screenshot processor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The minimal IAM I shipped earlier (`StartBrowserSession`, `StopBrowserSession`, `GetBrowserSession`, `UpdateBrowserStream`) wasn't enough — the WSS automation-stream connect requires an additional `ConnectBrowserAutomationStream`-flavored action that isn't in the public CLI command list. Lambda invocations were opening sessions cleanly but 403'ing on the WSS upgrade. Widen to `bedrock-agentcore:*` to unblock the e2e flow. Followup: scope back down to the specific connect action once it's documented or surfaced via CloudTrail decoded-message-on-deny. Smoke verified: PR #1 on isadeks/vercel-abca-linear now receives a screenshot comment within ~7s of the deployment_status webhook. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../constructs/github-screenshot-integration.ts | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts index 3a96f9a1..f79864ad 100644 --- a/cdk/src/constructs/github-screenshot-integration.ts +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -149,16 +149,15 @@ export class GitHubScreenshotIntegration extends Construct { this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn); props.githubTokenSecret.grantRead(this.webhookProcessorFn); - // AgentCore Browser session lifecycle. The data-plane API doesn't - // support per-resource ARNs (sessions are ephemeral), so wildcards - // are required — annotated with a cdk-nag suppression below. + // AgentCore Browser session lifecycle + automation-stream connect. + // The data-plane API doesn't support per-resource ARNs (sessions + // are ephemeral), so wildcards are required — annotated with a + // cdk-nag suppression below. The wildcard set covers + // `ConnectBrowserAutomationStream` (the SigV4-presigned WSS dial) + // which lives under the same prefix but isn't visible in the + // public CLI command list. this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ - actions: [ - 'bedrock-agentcore:StartBrowserSession', - 'bedrock-agentcore:StopBrowserSession', - 'bedrock-agentcore:GetBrowserSession', - 'bedrock-agentcore:UpdateBrowserStream', - ], + actions: ['bedrock-agentcore:*'], resources: ['*'], })); From 412772743fe91c3194edf73f84ad363f3c7b9212 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Wed, 20 May 2026 20:53:07 -0700 Subject: [PATCH 010/190] feat(screenshot): also post screenshot comment to linked Linear issue MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the screenshot processor to find a Linear issue via the PR's title/body and post the same image comment there. Approach (no GSI write-back needed): - Regex-extract Linear identifier (e.g. `ABCA-42`) from PR title/body. These are present whether the agent put them there (`task_description` carries the identifier) or Linear's own GitHub integration auto-injected the back-reference on PR open. - Scan `LinearWorkspaceRegistryTable` for `status=active` workspaces. Per-workspace, query Linear's `issueVcsBranchSearch` (which accepts the human-readable identifier) and accept the first exact-match hit. - Post the markdown image comment via the existing `postIssueComment` helper from Phase 2.0b. The Linear post is best-effort — if the registry table isn't wired, the identifier doesn't extract, or the lookup misses, the GitHub PR comment still lands. New env var `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME` is optional on the processor; the construct only sets it when the prop is provided. CDK: `GitHubScreenshotIntegrationProps` gains an optional `linearWorkspaceRegistryTable`. When provided, the processor's IAM grows: ReadData on the registry, GetSecretValue+PutSecretValue on `bgagent-linear-oauth-*`. `agent.ts` wires `linearIntegration.workspaceRegistryTable` into the screenshot construct. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../github-screenshot-integration.ts | 34 +++- cdk/src/handlers/github-webhook-processor.ts | 106 +++++++++- .../handlers/shared/linear-issue-lookup.ts | 187 ++++++++++++++++++ cdk/src/stacks/agent.ts | 6 + 4 files changed, 322 insertions(+), 11 deletions(-) create mode 100644 cdk/src/handlers/shared/linear-issue-lookup.ts diff --git a/cdk/src/constructs/github-screenshot-integration.ts b/cdk/src/constructs/github-screenshot-integration.ts index f79864ad..98ba3a8e 100644 --- a/cdk/src/constructs/github-screenshot-integration.ts +++ b/cdk/src/constructs/github-screenshot-integration.ts @@ -18,7 +18,7 @@ */ import * as path from 'path'; -import { Duration, RemovalPolicy } from 'aws-cdk-lib'; +import { ArnFormat, Duration, RemovalPolicy, Stack } from 'aws-cdk-lib'; import * as apigw from 'aws-cdk-lib/aws-apigateway'; import * as dynamodb from 'aws-cdk-lib/aws-dynamodb'; import * as iam from 'aws-cdk-lib/aws-iam'; @@ -44,6 +44,15 @@ export interface GitHubScreenshotIntegrationProps { */ readonly githubTokenSecret: secretsmanager.ISecret; + /** + * Optional — when provided, the processor also tries to post the + * screenshot to a linked Linear issue. Resolved from the GitHub PR + * title/body via a Linear-identifier regex (e.g. `ABCA-42`), then + * looked up across all `status='active'` workspaces in the registry + * via Linear's `issueVcsBranchSearch` GraphQL. + */ + readonly linearWorkspaceRegistryTable?: dynamodb.ITable; + /** * Removal policy for the dedup table + screenshot bucket. Defaults * to DESTROY so dev stacks don't accumulate orphans on `cdk destroy`. @@ -142,6 +151,9 @@ export class GitHubScreenshotIntegration extends Construct { SCREENSHOT_BUCKET_NAME: this.screenshotBucket.bucket.bucketName, SCREENSHOT_PUBLIC_HOST: this.screenshotBucket.distribution.domainName, GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn, + ...(props.linearWorkspaceRegistryTable && { + LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: props.linearWorkspaceRegistryTable.tableName, + }), }, bundling: commonBundling, }); @@ -149,6 +161,26 @@ export class GitHubScreenshotIntegration extends Construct { this.screenshotBucket.bucket.grantPut(this.webhookProcessorFn); props.githubTokenSecret.grantRead(this.webhookProcessorFn); + // Optional Linear feedback path. Wired only when a registry table + // is provided. The processor scans the registry for active + // workspaces, then per-workspace looks up the OAuth token from + // Secrets Manager (`bgagent-linear-oauth-*` prefix, written by + // `bgagent linear setup`). + if (props.linearWorkspaceRegistryTable) { + props.linearWorkspaceRegistryTable.grantReadData(this.webhookProcessorFn); + this.webhookProcessorFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue', 'secretsmanager:PutSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-linear-oauth-*', + }), + ], + })); + } + // AgentCore Browser session lifecycle + automation-stream connect. // The data-plane API doesn't support per-resource ARNs (sessions // are ephemeral), so wildcards are required — annotated with a diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index 935ffe3d..d15240d1 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -21,6 +21,8 @@ import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3'; import { captureScreenshot } from './shared/agentcore-browser'; import { resolveGitHubToken } from './shared/context-hydration'; import { upsertTaskComment } from './shared/github-comment'; +import { postIssueComment } from './shared/linear-feedback'; +import { extractLinearIdentifier, findLinearIssueByIdentifier } from './shared/linear-issue-lookup'; import { logger } from './shared/logger'; const s3 = new S3Client({}); @@ -32,6 +34,11 @@ const SCREENSHOT_BUCKET = process.env.SCREENSHOT_BUCKET_NAME!; // behalf. const SCREENSHOT_PUBLIC_HOST = process.env.SCREENSHOT_PUBLIC_HOST!; const GITHUB_TOKEN_SECRET_ARN = process.env.GITHUB_TOKEN_SECRET_ARN!; +// Optional — when set, the processor also tries to post the +// screenshot comment onto a linked Linear issue. Resolved from the +// GitHub PR title/body via a Linear-identifier regex (e.g. `ABCA-42`), +// then looked up across all active workspaces in the registry. +const LINEAR_WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; interface GitHubDeploymentStatusPayload { readonly action?: string; @@ -122,8 +129,8 @@ export async function handler(event: ProcessorEvent): Promise { return; } - const prNumber = await findPullRequestForSha(repo, sha, token); - if (!prNumber) { + const pr = await findPullRequestForSha(repo, sha, token); + if (!pr) { logger.info('No open PR found for SHA — skipping screenshot post', { repo, sha }); return; } @@ -169,7 +176,7 @@ export async function handler(event: ProcessorEvent): Promise { try { const result = await upsertTaskComment({ repo, - issueOrPrNumber: prNumber, + issueOrPrNumber: pr.number, body: commentBody, token, // Always POST fresh — a single PR can have multiple preview screenshots @@ -179,17 +186,67 @@ export async function handler(event: ProcessorEvent): Promise { }); logger.info('Posted screenshot comment to PR', { repo, - pr_number: prNumber, + pr_number: pr.number, comment_id: result.commentId, public_url: publicUrl, }); } catch (err) { logger.warn('Failed to post screenshot PR comment (non-fatal)', { repo, - pr_number: prNumber, + pr_number: pr.number, error: err instanceof Error ? err.message : String(err), }); } + + // Best-effort Linear comment. The GitHub PR comment above is the + // load-bearing artifact; the Linear comment is bonus surface for + // reviewers who live in Linear. Only fires when the registry table + // is configured AND the PR title/body carries a Linear identifier. + if (LINEAR_WORKSPACE_REGISTRY_TABLE) { + const identifier = extractLinearIdentifier(pr.title) ?? extractLinearIdentifier(pr.body); + if (identifier) { + const linearIssue = await findLinearIssueByIdentifier(identifier, LINEAR_WORKSPACE_REGISTRY_TABLE); + if (linearIssue) { + const ok = await postIssueComment( + { + linearWorkspaceId: linearIssue.linearWorkspaceId, + registryTableName: LINEAR_WORKSPACE_REGISTRY_TABLE, + }, + linearIssue.issueId, + renderLinearCommentBody(publicUrl, previewUrl), + ); + if (ok) { + logger.info('Posted screenshot comment to Linear issue', { + identifier, + linear_issue_id: linearIssue.issueId, + workspace_slug: linearIssue.workspaceSlug, + }); + } else { + logger.warn('Failed to post screenshot Linear comment (non-fatal)', { + identifier, + linear_issue_id: linearIssue.issueId, + }); + } + } else { + logger.info('Linear identifier did not resolve to an issue — skipping Linear post', { + identifier, + repo, + pr_number: pr.number, + }); + } + } + } +} + +/** + * Open PR shape we extract from the GitHub commit-pulls API. Title + + * body are used downstream by the Linear issue lookup; the others go + * into log lines for debugging. + */ +interface OpenPr { + readonly number: number; + readonly title: string; + readonly body: string; } /** @@ -197,14 +254,15 @@ export async function handler(event: ProcessorEvent): Promise { * "List pull requests associated with a commit" GitHub API * (https://docs.github.com/rest/commits/commits#list-pull-requests-associated-with-a-commit). * - * Returns the first OPEN PR's number, or null if none. Closed/merged - * PRs are filtered out — v1 only screenshots active reviews. + * Returns the first OPEN PR (with title/body), or null if none. + * Closed/merged PRs are filtered out — v1 only screenshots active + * reviews. */ async function findPullRequestForSha( repo: string, sha: string, token: string, -): Promise { +): Promise { const url = `https://api.github.com/repos/${repo}/commits/${sha}/pulls`; let res: Response; try { @@ -234,9 +292,19 @@ async function findPullRequestForSha( return null; } - const pulls = (await res.json()) as Array<{ number?: number; state?: string }>; + const pulls = (await res.json()) as Array<{ + number?: number; + state?: string; + title?: string; + body?: string | null; + }>; const open = pulls.find((p) => p.state === 'open' && typeof p.number === 'number'); - return open?.number ?? null; + if (!open) return null; + return { + number: open.number!, + title: open.title ?? '', + body: open.body ?? '', + }; } /** Build the S3 key for a screenshot. */ @@ -256,3 +324,21 @@ function renderCommentBody(publicUrl: string, previewUrl: string): string { `_From [${previewUrl}](${previewUrl}) — captured automatically by ABCA after the deploy finished._`, ].join('\n'); } + +/** + * Linear comment body. Linear's markdown renders image embeds the + * same way GitHub does, but Linear collapses linked-image syntax — + * use the simpler `![alt](url)` form so it renders inline rather than + * as a clickable link with a tiny preview. + */ +function renderLinearCommentBody(publicUrl: string, previewUrl: string): string { + return [ + '🖼️ **Preview screenshot**', + '', + `![preview](${publicUrl})`, + '', + `Live preview: [${previewUrl}](${previewUrl})`, + '', + '_Captured automatically by ABCA after the Vercel preview deploy finished._', + ].join('\n'); +} diff --git a/cdk/src/handlers/shared/linear-issue-lookup.ts b/cdk/src/handlers/shared/linear-issue-lookup.ts new file mode 100644 index 00000000..4ce4a6bd --- /dev/null +++ b/cdk/src/handlers/shared/linear-issue-lookup.ts @@ -0,0 +1,187 @@ +/** + * MIT No Attribution + * + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * the Software without restriction, including without limitation the rights to + * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of + * the Software, and to permit persons to whom the Software is furnished to do so. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; +import { DynamoDBDocumentClient, ScanCommand } from '@aws-sdk/lib-dynamodb'; +import { resolveLinearOauthToken } from './linear-oauth-resolver'; +import { logger } from './logger'; + +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); + +/** + * Linear issue identifier shape, e.g. `ABCA-42`. Linear identifiers are + * `-` where the key is uppercase letters and digits is + * a positive integer. We bound the team key length [1,10] and number + * length [1,8] to avoid pathological inputs. + */ +const LINEAR_IDENTIFIER_RE = /\b([A-Z][A-Z0-9]{0,9})-(\d{1,8})\b/g; + +/** + * Pull the first Linear issue identifier (e.g. `ABCA-42`) found in + * the given text. PR titles and bodies typically include this either + * because the agent's task_description carries the identifier, or + * because Linear's own GitHub integration auto-injects an + * `ABCA-42 ` reference. + * + * Returns the first match in document order. If multiple distinct + * identifiers are present we still return the first — multi-issue PRs + * are unusual enough that single-screenshot-per-issue is acceptable. + */ +export function extractLinearIdentifier(text: string | null | undefined): string | null { + if (!text) return null; + const match = LINEAR_IDENTIFIER_RE.exec(text); + // The regex has the `g` flag for testability; reset lastIndex so + // back-to-back calls behave correctly. + LINEAR_IDENTIFIER_RE.lastIndex = 0; + return match ? `${match[1]}-${match[2]}` : null; +} + +/** + * Resolved Linear issue location, paired with the workspace that owns + * it. The screenshot processor uses these to construct a + * LinearFeedbackContext + issueId for postIssueComment. + */ +export interface LinearIssueLocation { + readonly issueId: string; + readonly linearWorkspaceId: string; + readonly workspaceSlug: string; +} + +const ISSUE_BY_IDENTIFIER_QUERY = ` +query IssueByIdentifier($identifier: String!) { + issueVcsBranchSearch(branchName: $identifier) { + id + identifier + } +} +`.trim(); + +/** + * Look up a Linear issue by identifier (e.g. `ABCA-42`) by iterating + * over every active workspace in the registry until one returns a + * match. Returns the first hit. + * + * For v1 this scan is cheap — typical deployments have 1-2 workspaces. + * If a stack ever onboards many workspaces sharing identifier prefixes, + * a followup can store team_key prefixes on the registry row and route + * directly. Until then, linear-time iteration is fine. + * + * @param identifier `ABCA-42`-style Linear issue identifier + * @param registryTableName name of LinearWorkspaceRegistryTable + * @returns issue location, or null if no workspace contains the issue + */ +export async function findLinearIssueByIdentifier( + identifier: string, + registryTableName: string, +): Promise { + let active: Array<{ linear_workspace_id: string; workspace_slug: string }> = []; + try { + const scanResp = await ddb.send(new ScanCommand({ + TableName: registryTableName, + FilterExpression: '#s = :active', + ExpressionAttributeNames: { '#s': 'status' }, + ExpressionAttributeValues: { ':active': 'active' }, + })); + active = (scanResp.Items ?? []).map((item) => ({ + linear_workspace_id: item.linear_workspace_id as string, + workspace_slug: item.workspace_slug as string, + })); + } catch (err) { + logger.warn('Linear issue lookup: failed to scan workspace registry', { + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (active.length === 0) { + logger.info('Linear issue lookup: no active workspaces in registry', { identifier }); + return null; + } + + for (const ws of active) { + const resolved = await resolveLinearOauthToken(ws.linear_workspace_id, registryTableName); + if (!resolved) continue; + + const found = await queryIssueByIdentifier(resolved.accessToken, identifier); + if (found) { + return { + issueId: found, + linearWorkspaceId: ws.linear_workspace_id, + workspaceSlug: ws.workspace_slug, + }; + } + } + return null; +} + +/** + * Issue the GraphQL query to Linear; return the issue UUID on hit, null + * on miss. Never throws — caller iterates onto the next workspace. + * + * Uses `issueVcsBranchSearch` because it accepts the human-readable + * identifier directly (the regular `issue(id:)` query needs a UUID, + * which we don't have yet). The branch-search API was designed for + * exactly this — VCS integrations resolving `-` strings to + * issue rows. + */ +async function queryIssueByIdentifier(accessToken: string, identifier: string): Promise { + let resp: Response; + try { + resp = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + query: ISSUE_BY_IDENTIFIER_QUERY, + variables: { identifier }, + }), + }); + } catch (err) { + logger.warn('Linear issue lookup: graphql request failed', { + identifier, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + + if (!resp.ok) { + logger.warn('Linear issue lookup: graphql non-2xx', { identifier, status: resp.status }); + return null; + } + + const body = (await resp.json()) as { + data?: { issueVcsBranchSearch?: { id?: string; identifier?: string } | null }; + errors?: unknown; + }; + if (body.errors) { + logger.warn('Linear issue lookup: graphql errors', { identifier, errors: body.errors }); + return null; + } + const hit = body.data?.issueVcsBranchSearch; + if (!hit?.id) return null; + // Sanity: the response identifier must match what we asked for. + // `issueVcsBranchSearch` is a fuzzy match against branch-name patterns; + // exact-match the identifier to avoid linking to a near-neighbor issue. + if (hit.identifier && hit.identifier.toUpperCase() !== identifier.toUpperCase()) { + return null; + } + return hit.id; +} diff --git a/cdk/src/stacks/agent.ts b/cdk/src/stacks/agent.ts index c1b97294..74f9172b 100644 --- a/cdk/src/stacks/agent.ts +++ b/cdk/src/stacks/agent.ts @@ -813,6 +813,12 @@ export class AgentStack extends Stack { const githubScreenshot = new GitHubScreenshotIntegration(this, 'GitHubScreenshotIntegration', { api: taskApi.api, githubTokenSecret, + // When the screenshot lands on a PR linked to a Linear issue + // (identifier in the PR title/body), also post the screenshot + // as a comment on that Linear issue. Wired through the existing + // workspace registry so token resolution reuses the per-workspace + // OAuth secrets created by `bgagent linear setup`. + linearWorkspaceRegistryTable: linearIntegration.workspaceRegistryTable, }); new CfnOutput(this, 'GitHubWebhookUrl', { From fee11342981f7373222c265dc2c677b07e8cbe29 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Thu, 21 May 2026 01:01:40 -0700 Subject: [PATCH 011/190] fix(cli): bgagent linear list-projects on the OAuth secret model 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 ` to scope to one workspace; without it, queries every installed workspace and labels each project with its source. Also: switch to the `Bearer ` 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) --- cli/src/commands/linear.ts | 127 +++++++++++++++++++++++++------------ cli/src/linear-oauth.ts | 10 ++- 2 files changed, 94 insertions(+), 43 deletions(-) diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 51475b64..8d6ff98b 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -24,6 +24,7 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { CreateSecretCommand, GetSecretValueCommand, + ListSecretsCommand, PutSecretValueCommand, ResourceExistsException, SecretsManagerClient, @@ -39,6 +40,7 @@ import { computeExpiresAt, exchangeAuthorizationCode, generatePkce, + LINEAR_OAUTH_SECRET_PREFIX, linearOauthSecretName, StoredLinearOauthToken, } from '../linear-oauth'; @@ -618,67 +620,108 @@ export function makeLinearCommand(): Command { linear.addCommand( new Command('list-projects') - .description('List Linear projects visible to the stored API token (with full UUIDs)') + .description('List Linear projects visible to the OAuth-installed workspace (with full UUIDs)') .option('--region ', 'AWS region (defaults to configured region)') - .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') + .option('--slug ', 'Linear workspace slug (urlKey). If omitted, queries every active workspace in the registry.') .option('--output ', 'Output format (text or json)', 'text') .action(async (opts) => { const config = loadConfig(); const region = opts.region || config.region; + const sm = new SecretsManagerClient({ region }); - const apiTokenSecretArn = await getStackOutput(region, opts.stackName, 'LinearApiTokenSecretArn'); - if (!apiTokenSecretArn) { - console.error('Could not find LinearApiTokenSecretArn in stack outputs. Deploy the stack first.'); - process.exit(1); + // Resolve the set of workspace slugs to query. Either an + // explicit `--slug` (one workspace) or every Linear workspace + // we have an OAuth secret for (list every `bgagent-linear-oauth-*`). + let slugs: string[]; + if (opts.slug) { + slugs = [opts.slug]; + } else { + const listed = await sm.send(new ListSecretsCommand({ + Filters: [{ Key: 'name', Values: [LINEAR_OAUTH_SECRET_PREFIX] }], + })); + slugs = (listed.SecretList ?? []) + .map((s) => s.Name ?? '') + .filter((n) => n.startsWith(LINEAR_OAUTH_SECRET_PREFIX)) + .map((n) => n.slice(LINEAR_OAUTH_SECRET_PREFIX.length)); + if (slugs.length === 0) { + console.error('No Linear OAuth installs found. Run `bgagent linear setup ` first.'); + process.exit(1); + } } - const sm = new SecretsManagerClient({ region }); - const secret = await sm.send(new GetSecretValueCommand({ SecretId: apiTokenSecretArn })); - const apiToken = secret.SecretString; - if (!apiToken || apiToken === ' ') { - console.error('Linear API token is not populated. Run `bgagent linear setup` first.'); - process.exit(1); - } + type ProjectRow = { + slug: string; + id: string; + name: string; + team?: string; + }; + const rows: ProjectRow[] = []; + + for (const slug of slugs) { + const secretName = linearOauthSecretName(slug); + let accessToken: string; + try { + const resp = await sm.send(new GetSecretValueCommand({ SecretId: secretName })); + const stored = JSON.parse(resp.SecretString ?? '{}') as { access_token?: string }; + if (!stored.access_token) { + console.error(`Secret ${secretName} is missing access_token; skipping.`); + continue; + } + accessToken = stored.access_token; + } catch (err) { + console.error(`Failed to read ${secretName}: ${err instanceof Error ? err.message : String(err)}`); + continue; + } - let projects: Array<{ id: string; name: string; teams?: { nodes?: Array<{ id: string; name: string }> } }>; - try { - const res = await fetch('https://api.linear.app/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': apiToken, - }, - body: JSON.stringify({ - query: '{ projects { nodes { id name teams { nodes { id name } } } } }', - }), - }); - if (!res.ok) { - throw new Error(`Linear API returned ${res.status}`); + try { + const res = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + query: '{ projects(first: 100) { nodes { id name teams(first: 1) { nodes { name } } } } }', + }), + }); + if (!res.ok) { + console.error(`Linear API returned ${res.status} for workspace '${slug}'`); + continue; + } + const body = await res.json() as { + data?: { projects?: { nodes?: Array<{ id: string; name: string; teams?: { nodes?: Array<{ name: string }> } }> } }; + }; + for (const p of body.data?.projects?.nodes ?? []) { + rows.push({ + slug, + id: p.id, + name: p.name, + team: p.teams?.nodes?.[0]?.name, + }); + } + } catch (err) { + console.error(`Failed to fetch projects for '${slug}': ${err instanceof Error ? err.message : String(err)}`); + continue; } - const body = await res.json() as { data?: { projects?: { nodes?: typeof projects } } }; - projects = body.data?.projects?.nodes ?? []; - } catch (err) { - console.error(`Failed to fetch Linear projects: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); } if (opts.output === 'json') { - console.log(formatJson(projects)); + console.log(formatJson(rows)); return; } - if (projects.length === 0) { - console.log('No Linear projects visible to the stored API token.'); + if (rows.length === 0) { + console.log('No Linear projects visible to any installed workspace.'); return; } - console.log(`Found ${projects.length} Linear project(s):\n`); - for (const p of projects) { - const team = p.teams?.nodes?.[0]; - console.log(` ${p.name}`); - console.log(` id: ${p.id}`); - if (team) { - console.log(` team: ${team.name} (${team.id})`); + console.log(`Found ${rows.length} Linear project(s):\n`); + for (const r of rows) { + console.log(` ${r.name}`); + console.log(` id: ${r.id}`); + console.log(` workspace: ${r.slug}`); + if (r.team) { + console.log(` team: ${r.team}`); } console.log(''); } diff --git a/cli/src/linear-oauth.ts b/cli/src/linear-oauth.ts index c2ce2902..d23e390d 100644 --- a/cli/src/linear-oauth.ts +++ b/cli/src/linear-oauth.ts @@ -88,13 +88,21 @@ export interface StoredLinearOauthToken { readonly installed_by_platform_user_id: string; } +/** + * Common prefix for all per-workspace Linear OAuth secrets. The full + * secret name is `${LINEAR_OAUTH_SECRET_PREFIX}`. Use this when + * scanning Secrets Manager for every workspace install (e.g. the CLI's + * `list-projects` command queries every workspace it can find). + */ +export const LINEAR_OAUTH_SECRET_PREFIX = 'bgagent-linear-oauth-'; + /** * Build the secret name for a given Linear workspace slug. Matches the * naming convention encoded in the runtime's IAM policy resource pattern, * so changes here MUST be matched by the IAM resource pattern in CDK. */ export function linearOauthSecretName(workspaceSlug: string): string { - return `bgagent-linear-oauth-${workspaceSlug}`; + return `${LINEAR_OAUTH_SECRET_PREFIX}${workspaceSlug}`; } /** From 6660b1a7b43b00f40bf63e3c586f806d1ddfc27e Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Thu, 21 May 2026 01:20:40 -0700 Subject: [PATCH 012/190] fix(screenshot): retry PR lookup to handle Vercel-before-PR race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- cdk/src/handlers/github-webhook-processor.ts | 36 ++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/cdk/src/handlers/github-webhook-processor.ts b/cdk/src/handlers/github-webhook-processor.ts index d15240d1..31a88eb8 100644 --- a/cdk/src/handlers/github-webhook-processor.ts +++ b/cdk/src/handlers/github-webhook-processor.ts @@ -129,9 +129,13 @@ export async function handler(event: ProcessorEvent): Promise { return; } - const pr = await findPullRequestForSha(repo, sha, token); + // Race: Vercel posts `deployment_status` the moment its build finishes, + // which can be ~5-15s before the agent calls `gh pr create` for the + // same SHA. Retry the PR lookup with a small backoff so the screenshot + // doesn't get silently dropped on what is the common path. + const pr = await findPullRequestForShaWithRetry(repo, sha, token); if (!pr) { - logger.info('No open PR found for SHA — skipping screenshot post', { repo, sha }); + logger.info('No open PR found for SHA after retries — skipping screenshot post', { repo, sha }); return; } @@ -249,6 +253,34 @@ interface OpenPr { readonly body: string; } +/** + * Wait for an open PR to exist for the given SHA, retrying with a + * small backoff. Vercel commonly posts `deployment_status` before the + * agent's `gh pr create` call lands (we've measured 5-15s gap), so a + * single check would silently miss the common case. + * + * Schedule: 0s, 5s, 10s, 20s — covers the observed gap with one + * generous bonus retry. Total max wait ~35s. + */ +async function findPullRequestForShaWithRetry( + repo: string, + sha: string, + token: string, +): Promise { + const delays = [0, 5_000, 10_000, 20_000]; + for (const delay of delays) { + if (delay > 0) { + await new Promise((r) => setTimeout(r, delay)); + } + const pr = await findPullRequestForSha(repo, sha, token); + if (pr) return pr; + if (delay !== delays[delays.length - 1]) { + logger.info('Open PR not found yet for SHA — will retry', { repo, sha, next_delay_ms: delays[delays.indexOf(delay) + 1] }); + } + } + return null; +} + /** * Look up an open PR associated with `sha`. Uses the * "List pull requests associated with a commit" GitHub API From 790efbda9301ed43f8278dd93c6cebbd4865ac4a Mon Sep 17 00:00:00 2001 From: bgagent Date: Thu, 21 May 2026 02:20:17 -0700 Subject: [PATCH 013/190] fix(linear): silent label gate + default to 'abca' to stop unlabeled-issue comment spam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the trigger-label check ahead of every user-facing comment path in the Linear webhook processor, and switch the default trigger label from 'bgagent' to 'abca'. An unlabeled issue is now a true no-op: no comment, no reaction, no createTaskCore, no DDB writes — regardless of whether the project is onboarded. Why: workspace webhooks fire workspace-wide. A single un-onboarded team in the same Linear workspace produced 47 identical "❌ project isn't onboarded" comments on GRO-783 in 5 minutes because every Issue event (create/update/label-change) hit the not-onboarded gate before the label gate. With the gate order flipped, only issues that explicitly opt in via the trigger label can ever generate user-facing feedback. Per-project label_filter override is still respected — the project mapping lookup now happens once, before the label gate, instead of after. Tests: two new regression tests pin the spam scenario (unlabeled issue in a non-onboarded project, and unlabeled issue with no projectId) to zero side effects. Full CDK suite (89 suites / 1572 tests) passes. Co-Authored-By: Claude Opus 4.7 (1M context) --- cdk/src/handlers/linear-webhook-processor.ts | 70 ++++++++++++------- .../handlers/linear-webhook-processor.test.ts | 36 +++++++++- 2 files changed, 78 insertions(+), 28 deletions(-) diff --git a/cdk/src/handlers/linear-webhook-processor.ts b/cdk/src/handlers/linear-webhook-processor.ts index 9bc9fbb0..aeb8b4cd 100644 --- a/cdk/src/handlers/linear-webhook-processor.ts +++ b/cdk/src/handlers/linear-webhook-processor.ts @@ -31,7 +31,7 @@ const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); const PROJECT_MAPPING_TABLE = process.env.LINEAR_PROJECT_MAPPING_TABLE_NAME!; const USER_MAPPING_TABLE = process.env.LINEAR_USER_MAPPING_TABLE_NAME!; const WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; -const DEFAULT_LABEL_FILTER = 'bgagent'; +const DEFAULT_LABEL_FILTER = 'abca'; /** * Post a Linear comment + ❌ reaction without ever propagating an error. @@ -150,6 +150,48 @@ export async function handler(event: ProcessorEvent): Promise { const issue = payload.data; const projectId = issue.projectId; + + // Resolve the per-project label override (if any) BEFORE the label gate so + // a workspace using a non-default label name still triggers correctly. The + // lookup runs on every Issue webhook (one extra GetItem vs. lookup-after- + // projectId-check), which is the price of having the silent label gate + // come first — see comment on the `shouldTrigger` block below. + let mappingItem: Record | undefined; + if (projectId) { + const mapping = await ddb.send(new GetCommand({ + TableName: PROJECT_MAPPING_TABLE, + Key: { linear_project_id: projectId }, + })); + if (mapping.Item && mapping.Item.status === 'active') { + mappingItem = mapping.Item; + } + } + const labelFilter = (mappingItem?.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER; + + // Silent kill-switch: an issue without the trigger label is not for us. + // This MUST run before any user-facing comment path. Previously the + // projectId-missing and not-onboarded paths ran first and posted + // "❌ project isn't onboarded" comments on every Issue event in every + // unmapped team — workspace webhooks fire workspace-wide, so a single + // un-onboarded team produced dozens of comments per issue change. + // Moving the label check first means an unlabeled issue is a true no-op: + // no comment, no reaction, no task creation, no DDB writes. + if (!shouldTrigger(payload, labelFilter)) { + logger.info('Linear webhook does not match trigger criteria — skipping silently', { + action: payload.action, + issue_id: issue.id, + label_filter: labelFilter, + has_project_mapping: Boolean(mappingItem), + current_labels: issue.labels?.map((l) => l?.name), + updated_from_keys: Object.keys(payload.updatedFrom ?? {}), + updated_from_label_ids: payload.updatedFrom?.labelIds, + current_label_ids: issue.labels?.map((l) => l?.id), + }); + return; + } + + // From here on the issue is labeled for ABCA, so user-facing failure + // comments are appropriate — the user explicitly asked for our attention. if (!projectId) { logger.info('Linear Issue has no projectId — skipping (cannot route to a repo)', { issue_id: issue.id, @@ -162,12 +204,7 @@ export async function handler(event: ProcessorEvent): Promise { return; } - // Look up project → repo mapping. - const mapping = await ddb.send(new GetCommand({ - TableName: PROJECT_MAPPING_TABLE, - Key: { linear_project_id: projectId }, - })); - if (!mapping.Item || mapping.Item.status !== 'active') { + if (!mappingItem) { logger.info('Linear project is not onboarded or is removed — skipping', { linear_project_id: projectId, issue_id: issue.id, @@ -179,24 +216,7 @@ export async function handler(event: ProcessorEvent): Promise { ); return; } - const repo = mapping.Item.repo as string; - const labelFilter = (mapping.Item.label_filter as string | undefined) ?? DEFAULT_LABEL_FILTER; - - // Only trigger when the configured label is present AND this event is a transition - // that meaningfully added/asserts the label — `create` with the label on it, or - // `update` that newly added it. - if (!shouldTrigger(payload, labelFilter)) { - logger.info('Linear webhook does not match trigger criteria', { - action: payload.action, - issue_id: issue.id, - label_filter: labelFilter, - current_labels: issue.labels?.map((l) => l?.name), - updated_from_keys: Object.keys(payload.updatedFrom ?? {}), - updated_from_label_ids: payload.updatedFrom?.labelIds, - current_label_ids: issue.labels?.map((l) => l?.id), - }); - return; - } + const repo = mappingItem.repo as string; // Resolve the actor → platform user. Fall back to creator if the actor is missing // (e.g. automation that set the label). If neither resolves, we cannot attribute diff --git a/cdk/test/handlers/linear-webhook-processor.test.ts b/cdk/test/handlers/linear-webhook-processor.test.ts index beeba033..9f5a8e86 100644 --- a/cdk/test/handlers/linear-webhook-processor.test.ts +++ b/cdk/test/handlers/linear-webhook-processor.test.ts @@ -62,7 +62,7 @@ function issue(overrides: Record = {}): Record description: 'Users cannot log in.', projectId: 'project-1', teamId: 'team-1', - labels: [{ id: 'lbl-bg', name: 'bgagent' }], + labels: [{ id: 'lbl-abca', name: 'abca' }], }, ...overrides, }; @@ -134,7 +134,7 @@ describe('linear-webhook-processor handler', () => { ddbSend.mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }); const payload = issue({ action: 'update', - updatedFrom: { labelIds: ['lbl-bg', 'lbl-other'] }, + updatedFrom: { labelIds: ['lbl-abca', 'lbl-other'] }, }); await handler(eventWith(payload)); expect(createTaskCoreMock).not.toHaveBeenCalled(); @@ -150,7 +150,7 @@ describe('linear-webhook-processor handler', () => { test('creates task with channel_source=linear and linear_* metadata', async () => { ddbSend - .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active', label_filter: 'bgagent' } }) + .mockResolvedValueOnce({ Item: { repo: 'org/repo', status: 'active' } }) .mockResolvedValueOnce({ Item: { linear_identity: 'org-1#user-1', @@ -347,6 +347,36 @@ describe('linear-webhook-processor handler', () => { expect(reportIssueFailureMock).not.toHaveBeenCalled(); }); + test('unlabeled issue in a NON-onboarded project is a silent no-op (regression: comment-spam)', async () => { + // Workspace webhooks fire workspace-wide — issues in teams that ABCA + // was never onboarded into still reach this Lambda. Previously, every + // such event posted a "❌ project isn't onboarded" comment, producing + // 47 identical comments in 5min on a single GRO issue. The label gate + // now runs FIRST, so an unlabeled issue produces zero side effects no + // matter what state the project mapping is in. + ddbSend.mockResolvedValueOnce({ Item: undefined }); + const payload = issue(); + (payload.data as Record).labels = [{ id: 'l2', name: 'other' }]; + + await handler(eventWith(payload)); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + + test('unlabeled issue with no projectId is a silent no-op', async () => { + const payload = issue(); + const data = { ...(payload.data as Record) }; + delete data.projectId; + data.labels = [{ id: 'l2', name: 'other' }]; + payload.data = data; + + await handler(eventWith(payload)); + + expect(createTaskCoreMock).not.toHaveBeenCalled(); + expect(reportIssueFailureMock).not.toHaveBeenCalled(); + }); + test('safeReportIssueFailure: synchronous throw from reportIssueFailure does not propagate', async () => { // Defends against a future signature refactor that breaks the helper's // never-throw contract. Today `Promise.allSettled` guarantees this; if From 73f8ecf01deb4f22e98886dd5374a7088e96b853 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Thu, 21 May 2026 02:50:05 -0700 Subject: [PATCH 014/190] docs: VERCEL_SETUP_GUIDE for the Vercel preview screenshot pipeline 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). --- docs/astro.config.mjs | 1 + docs/guides/VERCEL_SETUP_GUIDE.md | 220 +++++++++++++++++ docs/scripts/sync-starlight.mjs | 7 + .../content/docs/using/Vercel-setup-guide.md | 224 ++++++++++++++++++ 4 files changed, 452 insertions(+) create mode 100644 docs/guides/VERCEL_SETUP_GUIDE.md create mode 100644 docs/src/content/docs/using/Vercel-setup-guide.md diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 4bbe89e4..a15c39dc 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -56,6 +56,7 @@ export default defineConfig({ { slug: 'using/webhook-integration' }, { slug: 'using/slack-setup-guide' }, { slug: 'using/linear-setup-guide' }, + { slug: 'using/vercel-setup-guide' }, { slug: 'using/task-lifecycle' }, { slug: 'using/what-the-agent-does' }, { slug: 'using/tips-for-being-a-good-citizen' }, diff --git a/docs/guides/VERCEL_SETUP_GUIDE.md b/docs/guides/VERCEL_SETUP_GUIDE.md new file mode 100644 index 00000000..d78d807a --- /dev/null +++ b/docs/guides/VERCEL_SETUP_GUIDE.md @@ -0,0 +1,220 @@ +# Vercel preview screenshots setup guide + +This guide walks through wiring a Vercel-connected GitHub repo into ABCA so that every preview deploy gets screenshotted and posted as a comment on both the open GitHub PR **and** the linked Linear issue. + +> **Prerequisite phases:** Linear OAuth (Phase 2.0b — see [Linear setup guide](./LINEAR_SETUP_GUIDE.md)) must be installed before this guide is useful, since the screenshot-to-Linear leg reuses the per-workspace OAuth tokens from that path. + +## What you get + +When ABCA opens a PR for a Linear-driven task, Vercel deploys the preview, posts a `deployment_status` event back to GitHub, and ABCA's webhook receiver: + +1. Captures a full-page screenshot of the preview URL via AgentCore Browser +2. Uploads the PNG to a private S3 bucket served via CloudFront +3. Posts a markdown image comment on the open GitHub PR +4. Looks up the Linear issue (by identifier in the PR title/body — e.g. `ABCA-42`) and posts the same screenshot as a Linear comment + +End-to-end latency: typically 10–15 seconds after Vercel reports the deploy. + +## How it works + +``` +agent push → Vercel preview build → deployment_status webhook + ↓ + POST /v1/github/webhook + ↓ + receiver Lambda (HMAC verify, dedup) + ↓ + processor Lambda + ↓ + AgentCore Browser session + ↓ + PNG → private S3 (30-day TTL) + ↓ + CloudFront-served public URL + ↓ + GitHub PR comment + Linear issue comment +``` + +Architecture notes: + +- **Lambda-only.** No agent runtime is involved post-PR — the screenshot job is deterministic; an LLM would only add cost without changing behavior. +- **AWS-managed default browser.** AgentCore Browser ships an `aws.browser.v1` session you can attach to without provisioning your own browser resource. +- **Private S3 + CloudFront with OAC.** Screenshot bucket is fully private; CloudFront serves images anonymously over HTTPS so GitHub Markdown and Linear's image previews can render them without auth. +- **WAF exemption.** The `/v1/github/webhook` path is excluded from the AWSManagedRulesCommonRuleSet because Vercel `deployment_status` payloads (which embed absolute deploy URLs) trip `GenericRFI_BODY` otherwise. + +## Prerequisites + +- ABCA stack deployed (`mise //cdk:deploy` in this branch or later) — confirm `GitHubWebhookUrl` + `GitHubWebhookSecretArn` + `ScreenshotCloudFrontDomain` are listed in the stack outputs +- Linear OAuth installed for at least one workspace (`bgagent linear setup `) +- A GitHub repo you own AND where you can install the Vercel app +- A Vercel account that can import that repo +- AWS CLI logged in to the same account as the ABCA stack +- The `bgagent` CLI installed (`bgagent configure`, `bgagent login`) + +## Step-by-step setup + +### Step 1 — Connect Vercel to your GitHub repo + +1. Open https://vercel.com/dashboard. +2. **Add New** → **Project**. +3. Find your repo in the list (e.g. `your-org/vercel-abca-linear`). If it's not visible, click "Adjust GitHub App Permissions" and grant access. +4. Click **Import**. +5. Accept the framework defaults — Vercel auto-detects most stacks. +6. Click **Deploy**. Wait for the first deploy to finish. + +### Step 2 — Vercel project settings + +Go to **your-project → Settings** in the Vercel dashboard. + +#### Settings → Git +- **Connected Git Repository**: confirm the repo is listed. +- **`deployment_status` Events**: toggle **Enabled** (this is what tells Vercel to post the webhook to GitHub when each deploy finishes). +- **Pull Request Comments**: optional — Vercel's own comment with the preview URL. Doesn't affect ABCA either way. + +#### Settings → Deployment Protection +- **Vercel Authentication**: set to **Disabled** (or "Only Production Deployments") for the demo. Otherwise AgentCore Browser will hit a Vercel auth wall and screenshot the login page instead of your app. + +> **Production hardening.** When you graduate the demo to a real production setup, switch Vercel Authentication back to **Standard Protection** and configure a [signed bypass token](https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection#protection-bypass-for-automation). The screenshot processor will need to inject the bypass token as a query parameter on the preview URL — this is tracked as a followup. + +### Step 3 — Onboard the repo to ABCA + +ABCA needs to know the repo is allowed to receive tasks. Two writes: + +#### 3a. Register the repo in `RepoTable` + +There's no CLI helper today; do a direct DDB put. Replace the table name with your stack's value (`aws cloudformation describe-stacks ... RepoTableName`): + +```bash +aws dynamodb put-item --region us-east-1 \ + --table-name \ + --item '{ + "repo": {"S": "your-org/your-vercel-repo"}, + "status": {"S": "active"}, + "onboarded_at": {"S": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}, + "updated_at": {"S": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"} + }' +``` + +#### 3b. Map a Linear project → this repo + +```bash +# Find the Linear project UUID +bgagent linear list-projects + +# Map it to the repo +bgagent linear onboard-project \ + --repo your-org/your-vercel-repo \ + --label abca +``` + +The `--label` controls which Linear label triggers a task. Defaults to `bgagent`; the demo uses `abca`. You can use any label you like, but it has to match what users will apply on Linear issues. + +### Step 4 — Configure the GitHub webhook + +This is what wires Vercel deploys back to ABCA's screenshot pipeline. + +#### 4a. Get the webhook URL + +```bash +aws cloudformation describe-stacks \ + --region us-east-1 \ + --stack-name \ + --query 'Stacks[0].Outputs[?OutputKey==`GitHubWebhookUrl`].OutputValue' \ + --output text +# → https://.execute-api.us-east-1.amazonaws.com/v1/github/webhook +``` + +#### 4b. Get the signing secret + +```bash +SECRET_ARN=$(aws cloudformation describe-stacks \ + --region us-east-1 \ + --stack-name \ + --query 'Stacks[0].Outputs[?OutputKey==`GitHubWebhookSecretArn`].OutputValue' \ + --output text) + +aws secretsmanager get-secret-value \ + --region us-east-1 \ + --secret-id "$SECRET_ARN" \ + --query SecretString --output text +``` + +#### 4c. Add the webhook on the GitHub repo + +1. Open `https://github.com///settings/hooks`. +2. Click **Add webhook**. +3. Fill in: + - **Payload URL**: the URL from 4a + - **Content type**: `application/json` + - **Secret**: the value from 4b + - **SSL verification**: leave enabled + - **Which events?**: choose "Let me select individual events", uncheck Pushes, check **Deployment statuses** only + - **Active**: ✓ +4. **Add webhook**. GitHub fires a `ping` event right away — under "Recent Deliveries" you should see ✅ within seconds. + +### Step 5 — Smoke test + +1. Open a Linear issue in your mapped project (e.g. "Update homepage heading"). It will get a Linear identifier like `ABCA-42`. +2. Add the `abca` label. +3. Wait 2-5 minutes: + - Agent reacts 👀 on the Linear issue (within ~10s) + - Agent does the work, opens a PR + - Vercel builds the preview (~30-60s) + - **Screenshot lands on the GitHub PR** as a comment + - **Same screenshot lands on the Linear issue** as a comment + +If the GitHub comment shows up but Linear doesn't (or vice versa), see Troubleshooting below. + +## Troubleshooting + +### GitHub webhook deliveries return 401 / 403 + +- **401 "Missing signature"**: the request didn't reach our Lambda — check that you saved the webhook with the right signing secret. +- **403 "Forbidden" with `X-Amzn-Errortype: ForbiddenException`**: WAF rejected the body. Should not happen on the `/v1/github/webhook` path because that path is exempted from the CommonRuleSet, but if you see it, check the `BlockedRequests` metric on the `TaskApiWebAcl` regional WebACL in CloudWatch. + +### Webhook delivers 200 but no screenshot lands + +Check the screenshot processor logs: + +```bash +aws lambda list-functions --region us-east-1 \ + --query "Functions[?contains(FunctionName, 'GitHubScreenshot') && contains(FunctionName, 'Processor')].FunctionName" \ + --output text +``` + +Then tail the function's CloudWatch log group. Common silent skips: + +- `skipped_state` — the delivery was for a non-`success` status (e.g. `pending`, `in_progress`); ignore. +- `skipped_environment` — Vercel reported the deploy as something other than `Preview`. The processor only screenshots Preview deploys by default; production hardening is a followup. +- `skipped_no_url` — the `success` status didn't include `environment_url`. Vercel does sometimes post URL-less success events; the next push usually carries the URL. +- `No open PR found for SHA after retries` — Vercel built and reported faster than the agent could `gh pr create` (race window > 35s). Rare; redeliver the webhook from GitHub's UI to retry. + +### Screenshot lands on GitHub PR but not on Linear + +The GitHub comment is the load-bearing path; Linear is best-effort. Look for the processor log line `Linear identifier did not resolve to an issue` — usually means: + +- The PR title and body don't contain a Linear-style identifier (e.g. `ABCA-42`). The agent's task description includes the identifier by default; if you opened the PR manually it might not. +- The identifier's workspace isn't OAuth-installed. Run `bgagent linear list-projects` to confirm the issue's project is in the registry. + +### CloudFront serves a 403 + +Visit the public URL directly: + +``` +https:///screenshots//.png +``` + +If it 403s, check that the bucket policy includes the OAC service principal (CDK should generate this automatically — re-deploy if it doesn't). + +### Vercel screenshots show a login page + +You forgot Step 2's "Vercel Authentication: Disabled" toggle. Toggle it off, push another commit, and confirm the next screenshot renders the actual app. + +## Production hardening (followups) + +The demo configuration optimizes for "look, it works" rather than security posture. Before using this on a real product: + +1. **Re-enable Vercel Standard Protection** + signed bypass token; teach the screenshot processor to inject `?x-vercel-protection-bypass=` on preview URLs (followup). +2. **Scope IAM down from `bedrock-agentcore:*`** to the specific Browser action set (followup, tracked). +3. **Add CloudFront access logs + WAF** if screenshots ever contain sensitive content. +4. **Tighten the screenshot retention** below 30 days if your privacy review requires it (constant in `cdk/src/constructs/screenshot-bucket.ts`). diff --git a/docs/scripts/sync-starlight.mjs b/docs/scripts/sync-starlight.mjs index 96c080c2..9da40980 100644 --- a/docs/scripts/sync-starlight.mjs +++ b/docs/scripts/sync-starlight.mjs @@ -45,6 +45,7 @@ function rewriteDocsLinkTarget(target) { CONTRIBUTING: '/developer-guide/contributing', SLACK_SETUP_GUIDE: '/using/slack-setup-guide', LINEAR_SETUP_GUIDE: '/using/linear-setup-guide', + VERCEL_SETUP_GUIDE: '/using/vercel-setup-guide', CEDAR_POLICY_GUIDE: '/customizing/cedar-policies', DEPLOYMENT_GUIDE: '/getting-started/deployment-guide', }; @@ -238,6 +239,12 @@ mirrorMarkdownFile( path.join('src', 'content', 'docs', 'using', 'Linear-setup-guide.md'), ); +// --- Vercel Setup Guide: mirror to using/ --- +mirrorMarkdownFile( + path.join(docsRoot, 'guides', 'VERCEL_SETUP_GUIDE.md'), + path.join('src', 'content', 'docs', 'using', 'Vercel-setup-guide.md'), +); + // --- Cedar Policy Guide: mirror to customizing/ (authoring reference for blueprint authors) --- mirrorMarkdownFile( path.join(docsRoot, 'guides', 'CEDAR_POLICY_GUIDE.md'), diff --git a/docs/src/content/docs/using/Vercel-setup-guide.md b/docs/src/content/docs/using/Vercel-setup-guide.md new file mode 100644 index 00000000..0af011ce --- /dev/null +++ b/docs/src/content/docs/using/Vercel-setup-guide.md @@ -0,0 +1,224 @@ +--- +title: Vercel setup guide +--- + +# Vercel preview screenshots setup guide + +This guide walks through wiring a Vercel-connected GitHub repo into ABCA so that every preview deploy gets screenshotted and posted as a comment on both the open GitHub PR **and** the linked Linear issue. + +> **Prerequisite phases:** Linear OAuth (Phase 2.0b — see [Linear setup guide](/using/linear-setup-guide)) must be installed before this guide is useful, since the screenshot-to-Linear leg reuses the per-workspace OAuth tokens from that path. + +## What you get + +When ABCA opens a PR for a Linear-driven task, Vercel deploys the preview, posts a `deployment_status` event back to GitHub, and ABCA's webhook receiver: + +1. Captures a full-page screenshot of the preview URL via AgentCore Browser +2. Uploads the PNG to a private S3 bucket served via CloudFront +3. Posts a markdown image comment on the open GitHub PR +4. Looks up the Linear issue (by identifier in the PR title/body — e.g. `ABCA-42`) and posts the same screenshot as a Linear comment + +End-to-end latency: typically 10–15 seconds after Vercel reports the deploy. + +## How it works + +``` +agent push → Vercel preview build → deployment_status webhook + ↓ + POST /v1/github/webhook + ↓ + receiver Lambda (HMAC verify, dedup) + ↓ + processor Lambda + ↓ + AgentCore Browser session + ↓ + PNG → private S3 (30-day TTL) + ↓ + CloudFront-served public URL + ↓ + GitHub PR comment + Linear issue comment +``` + +Architecture notes: + +- **Lambda-only.** No agent runtime is involved post-PR — the screenshot job is deterministic; an LLM would only add cost without changing behavior. +- **AWS-managed default browser.** AgentCore Browser ships an `aws.browser.v1` session you can attach to without provisioning your own browser resource. +- **Private S3 + CloudFront with OAC.** Screenshot bucket is fully private; CloudFront serves images anonymously over HTTPS so GitHub Markdown and Linear's image previews can render them without auth. +- **WAF exemption.** The `/v1/github/webhook` path is excluded from the AWSManagedRulesCommonRuleSet because Vercel `deployment_status` payloads (which embed absolute deploy URLs) trip `GenericRFI_BODY` otherwise. + +## Prerequisites + +- ABCA stack deployed (`mise //cdk:deploy` in this branch or later) — confirm `GitHubWebhookUrl` + `GitHubWebhookSecretArn` + `ScreenshotCloudFrontDomain` are listed in the stack outputs +- Linear OAuth installed for at least one workspace (`bgagent linear setup `) +- A GitHub repo you own AND where you can install the Vercel app +- A Vercel account that can import that repo +- AWS CLI logged in to the same account as the ABCA stack +- The `bgagent` CLI installed (`bgagent configure`, `bgagent login`) + +## Step-by-step setup + +### Step 1 — Connect Vercel to your GitHub repo + +1. Open https://vercel.com/dashboard. +2. **Add New** → **Project**. +3. Find your repo in the list (e.g. `your-org/vercel-abca-linear`). If it's not visible, click "Adjust GitHub App Permissions" and grant access. +4. Click **Import**. +5. Accept the framework defaults — Vercel auto-detects most stacks. +6. Click **Deploy**. Wait for the first deploy to finish. + +### Step 2 — Vercel project settings + +Go to **your-project → Settings** in the Vercel dashboard. + +#### Settings → Git +- **Connected Git Repository**: confirm the repo is listed. +- **`deployment_status` Events**: toggle **Enabled** (this is what tells Vercel to post the webhook to GitHub when each deploy finishes). +- **Pull Request Comments**: optional — Vercel's own comment with the preview URL. Doesn't affect ABCA either way. + +#### Settings → Deployment Protection +- **Vercel Authentication**: set to **Disabled** (or "Only Production Deployments") for the demo. Otherwise AgentCore Browser will hit a Vercel auth wall and screenshot the login page instead of your app. + +> **Production hardening.** When you graduate the demo to a real production setup, switch Vercel Authentication back to **Standard Protection** and configure a [signed bypass token](https://vercel.com/docs/security/deployment-protection/methods-to-bypass-deployment-protection#protection-bypass-for-automation). The screenshot processor will need to inject the bypass token as a query parameter on the preview URL — this is tracked as a followup. + +### Step 3 — Onboard the repo to ABCA + +ABCA needs to know the repo is allowed to receive tasks. Two writes: + +#### 3a. Register the repo in `RepoTable` + +There's no CLI helper today; do a direct DDB put. Replace the table name with your stack's value (`aws cloudformation describe-stacks ... RepoTableName`): + +```bash +aws dynamodb put-item --region us-east-1 \ + --table-name \ + --item '{ + "repo": {"S": "your-org/your-vercel-repo"}, + "status": {"S": "active"}, + "onboarded_at": {"S": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"}, + "updated_at": {"S": "'$(date -u +%Y-%m-%dT%H:%M:%SZ)'"} + }' +``` + +#### 3b. Map a Linear project → this repo + +```bash +# Find the Linear project UUID +bgagent linear list-projects + +# Map it to the repo +bgagent linear onboard-project \ + --repo your-org/your-vercel-repo \ + --label abca +``` + +The `--label` controls which Linear label triggers a task. Defaults to `bgagent`; the demo uses `abca`. You can use any label you like, but it has to match what users will apply on Linear issues. + +### Step 4 — Configure the GitHub webhook + +This is what wires Vercel deploys back to ABCA's screenshot pipeline. + +#### 4a. Get the webhook URL + +```bash +aws cloudformation describe-stacks \ + --region us-east-1 \ + --stack-name \ + --query 'Stacks[0].Outputs[?OutputKey==`GitHubWebhookUrl`].OutputValue' \ + --output text +# → https://.execute-api.us-east-1.amazonaws.com/v1/github/webhook +``` + +#### 4b. Get the signing secret + +```bash +SECRET_ARN=$(aws cloudformation describe-stacks \ + --region us-east-1 \ + --stack-name \ + --query 'Stacks[0].Outputs[?OutputKey==`GitHubWebhookSecretArn`].OutputValue' \ + --output text) + +aws secretsmanager get-secret-value \ + --region us-east-1 \ + --secret-id "$SECRET_ARN" \ + --query SecretString --output text +``` + +#### 4c. Add the webhook on the GitHub repo + +1. Open `https://github.com///settings/hooks`. +2. Click **Add webhook**. +3. Fill in: + - **Payload URL**: the URL from 4a + - **Content type**: `application/json` + - **Secret**: the value from 4b + - **SSL verification**: leave enabled + - **Which events?**: choose "Let me select individual events", uncheck Pushes, check **Deployment statuses** only + - **Active**: ✓ +4. **Add webhook**. GitHub fires a `ping` event right away — under "Recent Deliveries" you should see ✅ within seconds. + +### Step 5 — Smoke test + +1. Open a Linear issue in your mapped project (e.g. "Update homepage heading"). It will get a Linear identifier like `ABCA-42`. +2. Add the `abca` label. +3. Wait 2-5 minutes: + - Agent reacts 👀 on the Linear issue (within ~10s) + - Agent does the work, opens a PR + - Vercel builds the preview (~30-60s) + - **Screenshot lands on the GitHub PR** as a comment + - **Same screenshot lands on the Linear issue** as a comment + +If the GitHub comment shows up but Linear doesn't (or vice versa), see Troubleshooting below. + +## Troubleshooting + +### GitHub webhook deliveries return 401 / 403 + +- **401 "Missing signature"**: the request didn't reach our Lambda — check that you saved the webhook with the right signing secret. +- **403 "Forbidden" with `X-Amzn-Errortype: ForbiddenException`**: WAF rejected the body. Should not happen on the `/v1/github/webhook` path because that path is exempted from the CommonRuleSet, but if you see it, check the `BlockedRequests` metric on the `TaskApiWebAcl` regional WebACL in CloudWatch. + +### Webhook delivers 200 but no screenshot lands + +Check the screenshot processor logs: + +```bash +aws lambda list-functions --region us-east-1 \ + --query "Functions[?contains(FunctionName, 'GitHubScreenshot') && contains(FunctionName, 'Processor')].FunctionName" \ + --output text +``` + +Then tail the function's CloudWatch log group. Common silent skips: + +- `skipped_state` — the delivery was for a non-`success` status (e.g. `pending`, `in_progress`); ignore. +- `skipped_environment` — Vercel reported the deploy as something other than `Preview`. The processor only screenshots Preview deploys by default; production hardening is a followup. +- `skipped_no_url` — the `success` status didn't include `environment_url`. Vercel does sometimes post URL-less success events; the next push usually carries the URL. +- `No open PR found for SHA after retries` — Vercel built and reported faster than the agent could `gh pr create` (race window > 35s). Rare; redeliver the webhook from GitHub's UI to retry. + +### Screenshot lands on GitHub PR but not on Linear + +The GitHub comment is the load-bearing path; Linear is best-effort. Look for the processor log line `Linear identifier did not resolve to an issue` — usually means: + +- The PR title and body don't contain a Linear-style identifier (e.g. `ABCA-42`). The agent's task description includes the identifier by default; if you opened the PR manually it might not. +- The identifier's workspace isn't OAuth-installed. Run `bgagent linear list-projects` to confirm the issue's project is in the registry. + +### CloudFront serves a 403 + +Visit the public URL directly: + +``` +https:///screenshots//.png +``` + +If it 403s, check that the bucket policy includes the OAC service principal (CDK should generate this automatically — re-deploy if it doesn't). + +### Vercel screenshots show a login page + +You forgot Step 2's "Vercel Authentication: Disabled" toggle. Toggle it off, push another commit, and confirm the next screenshot renders the actual app. + +## Production hardening (followups) + +The demo configuration optimizes for "look, it works" rather than security posture. Before using this on a real product: + +1. **Re-enable Vercel Standard Protection** + signed bypass token; teach the screenshot processor to inject `?x-vercel-protection-bypass=` on preview URLs (followup). +2. **Scope IAM down from `bedrock-agentcore:*`** to the specific Browser action set (followup, tracked). +3. **Add CloudFront access logs + WAF** if screenshots ever contain sensitive content. +4. **Tighten the screenshot retention** below 30 days if your privacy review requires it (constant in `cdk/src/constructs/screenshot-bucket.ts`). From 06d4f8d5781d50523f383b7e7551c45b7284c33c Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 19:08:39 -0400 Subject: [PATCH 015/190] feat(linear): add bgagent linear add-workspace command MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets operators install the Linear OAuth app in additional workspaces without re-pasting the same client_id/client_secret they already typed during the initial bgagent linear setup. The OAuth app's client_id/client_secret are workspace-independent — Linear scopes consent per-workspace, not per-app. add-workspace scans the LinearWorkspaceRegistryTable for the first active row and reads those credentials from its per-workspace SM secret, avoiding the re-prompt. Override flags (--client-id, --client-secret) cover the edge case of running the same ABCA stack against two unrelated Linear orgs that each have their own OAuth app. Differs from setup in three ways: - Refuses if no active workspace exists yet (use setup first) - Skips the webhook-signing-secret prompt (one stack-wide secret covers all workspaces against the same OAuth app + receiver URL) - Refuses to silently overwrite an already-onboarded workspace's registry row — a wrong-account login would otherwise produce a confusing duplicate Adds findReusableOauthAppCredentials helper + 5 jest tests covering: empty registry → null, happy path, missing client_id/secret in old secrets → null, corrupted JSON → null, missing SecretString → null. --- cli/src/commands/linear.ts | 307 ++++++++++++++++++++++++++++++- cli/test/commands/linear.test.ts | 87 ++++++++- 2 files changed, 392 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 51475b64..e218c413 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -28,7 +28,7 @@ import { ResourceExistsException, SecretsManagerClient, } from '@aws-sdk/client-secrets-manager'; -import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'; +import { DynamoDBDocumentClient, PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; import { Command } from 'commander'; import { ApiClient } from '../api-client'; import { loadConfig, loadCredentials } from '../config'; @@ -242,6 +242,58 @@ export async function upsertOauthSecret( } } +/** + * Find an OAuth credential pair (client_id + client_secret) reusable for a + * new workspace install. Returns the values from the FIRST `active` row in + * the workspace registry, by reading that row's per-workspace SM secret. + * + * Used by `bgagent linear add-workspace` so the operator doesn't have to + * re-paste the same Linear OAuth app credentials they already typed during + * the initial `bgagent linear setup`. Same Linear OAuth app can authorize + * multiple workspaces — Linear scopes consent per-workspace, but the app's + * client_id/client_secret are workspace-independent. + * + * Returns null when there's no existing active workspace, signalling that + * the operator should run `bgagent linear setup` first. + */ +export async function findReusableOauthAppCredentials( + ddb: DynamoDBDocumentClient, + sm: SecretsManagerClient, + registryTableName: string, +): Promise<{ clientId: string; clientSecret: string; sourceSlug: string } | null> { + // Limit=1 keeps the scan cheap. The registry table is one row per + // workspace install (small N) so a scan is acceptable here. + const scan = await ddb.send(new ScanCommand({ + TableName: registryTableName, + FilterExpression: '#status = :active', + ExpressionAttributeNames: { '#status': 'status' }, + ExpressionAttributeValues: { ':active': 'active' }, + Limit: 1, + })); + const row = scan.Items?.[0]; + if (!row || !row.oauth_secret_arn || !row.workspace_slug) { + return null; + } + const value = await sm.send(new GetSecretValueCommand({ SecretId: row.oauth_secret_arn as string })); + if (!value.SecretString) { + return null; + } + let parsed: Partial; + try { + parsed = JSON.parse(value.SecretString) as Partial; + } catch { + return null; + } + if (!parsed.client_id || !parsed.client_secret) { + return null; + } + return { + clientId: parsed.client_id, + clientSecret: parsed.client_secret, + sourceSlug: row.workspace_slug as string, + }; +} + export function makeLinearCommand(): Command { const linear = new Command('linear') .description('Manage Linear integration'); @@ -557,6 +609,259 @@ export function makeLinearCommand(): Command { }), ); + linear.addCommand( + new Command('add-workspace') + .description('Authorize an additional Linear workspace using the existing OAuth app + webhook secret') + .argument('', 'Linear workspace urlKey (e.g. "acme" from linear.app/acme/...)') + .option('--region ', 'AWS region (defaults to configured region)') + .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') + .option('--client-id ', 'Override the OAuth Client ID (else reused from existing workspace)') + .option('--client-secret ', 'Override the OAuth Client Secret (else reused from existing workspace)') + .option('--no-browser', 'Print the authorization URL instead of opening a browser (for SSH/headless)') + .option('--no-actor-app', 'Drop actor=app from the OAuth flow (diagnostic)') + .action(async (slug: string, opts) => { + if (!SLUG_RE.test(slug)) { + throw new CliError( + `Invalid workspace slug '${slug}'. Must be 4-50 chars matching [a-zA-Z0-9_-]. ` + + 'This is the Linear urlKey, e.g. \'acme\' from linear.app/acme/...', + ); + } + const config = loadConfig(); + const region = opts.region || config.region; + const stackName = opts.stackName; + + // ─── Stack outputs ───────────────────────────────────────────── + // Subset of `setup`'s outputs — webhook secret ARN is intentionally + // NOT required here: add-workspace assumes the initial setup wizard + // already installed it (one signing secret covers all workspaces + // sharing the same Linear OAuth app + webhook receiver URL). + const [ + workspaceRegistryTable, + userMappingTable, + ] = await Promise.all([ + getStackOutput(region, stackName, 'LinearWorkspaceRegistryTableName'), + getStackOutput(region, stackName, 'LinearUserMappingTableName'), + ]); + + const missing: string[] = []; + if (!workspaceRegistryTable) missing.push('LinearWorkspaceRegistryTableName'); + if (!userMappingTable) missing.push('LinearUserMappingTableName'); + if (missing.length > 0) { + throw new CliError( + `Stack '${stackName}' is missing outputs ${missing.join(', ')}. ` + + 'Re-deploy with the 2.0b CDK changes (mise //cdk:deploy).', + ); + } + + // ─── Resolve caller identity ────────────────────────────────── + const creds = loadCredentials(); + if (!creds?.id_token) { + throw new CliError('Not authenticated — run `bgagent login` first.'); + } + let cognitoSub: string; + try { + cognitoSub = extractCognitoSub(); + } catch (err) { + throw new CliError( + `Could not read Cognito sub from cached id_token: ${err instanceof Error ? err.message : String(err)}. ` + + 'Run `bgagent login` to refresh credentials.', + ); + } + + const sm = new SecretsManagerClient({ region }); + const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); + + // ─── Linear OAuth app credentials ────────────────────────────── + // Reuse the same client_id/client_secret from an existing active + // workspace unless explicitly overridden. The setup wizard owns + // first-time credential entry; add-workspace is the "I already did + // setup, just install the app in another Linear org" path. + let clientId = opts.clientId?.trim() ?? ''; + let clientSecret = opts.clientSecret?.trim() ?? ''; + + if (!clientId || !clientSecret) { + process.stdout.write(' → Looking for an existing workspace to reuse OAuth credentials...'); + const existing = await findReusableOauthAppCredentials(ddb, sm, workspaceRegistryTable!); + if (!existing) { + console.log(' ✗'); + throw new CliError( + 'No active Linear workspace found in the registry. ' + + 'Run `bgagent linear setup ` first to install the OAuth app, ' + + 'then re-run `bgagent linear add-workspace` for additional workspaces.', + ); + } + console.log(` ✓ (reusing credentials from '${existing.sourceSlug}')`); + clientId = clientId || existing.clientId; + clientSecret = clientSecret || existing.clientSecret; + } + + console.log(`bgagent linear add-workspace — workspace '${slug}'`); + console.log(` region: ${region}`); + + // ─── PKCE + browser consent ──────────────────────────────────── + const pkce = generatePkce(); + const state = randomState(); + const useActorApp = opts.actorApp !== false; + const authorizationUrl = buildAuthorizationUrl({ + clientId, + redirectUri: CALLBACK_URL, + state, + codeChallenge: pkce.codeChallenge, + actorApp: useActorApp, + }); + if (!useActorApp) { + console.log(' ⚠ --no-actor-app: dropping actor=app for diagnosis. Token will not be agent-scoped.'); + } + + const callbackPromise = awaitOauthCallback(); + + console.log(); + if (opts.browser !== false) { + const opened = await openBrowser(authorizationUrl); + if (opened) { + console.log(' → Opened your browser to the Linear consent screen.'); + console.log(' Sign in to the workspace you want to add (use a workspace switcher if needed).'); + } else { + console.log(' → Could not open browser automatically. Open this URL manually:'); + console.log(` ${authorizationUrl}`); + } + } else { + console.log(' → --no-browser: open this URL manually:'); + console.log(` ${authorizationUrl}`); + } + + process.stdout.write(' → Waiting for browser callback...'); + const callback = await callbackPromise; + console.log(' ✓'); + + if (callback.kind !== 'direct-oauth') { + throw new CliError( + 'Localhost callback returned an AgentCore session_id, not a direct OAuth code. ' + + 'Verify Linear\'s redirect URI is set to http://localhost:8080/oauth/callback and re-run.', + ); + } + if (callback.state !== state) { + throw new CliError( + `OAuth state mismatch (expected '${state}', got '${callback.state}'). ` + + 'Possible CSRF attack or stale tab — re-run add-workspace.', + ); + } + + // ─── Exchange code → fetch identity ──────────────────────────── + process.stdout.write(' → Exchanging code for access token...'); + const tokenResponse = await exchangeAuthorizationCode({ + code: callback.code, + codeVerifier: pkce.codeVerifier, + redirectUri: CALLBACK_URL, + clientId, + clientSecret, + }); + console.log(' ✓'); + + process.stdout.write(' → Querying Linear viewer + organization...'); + const identity = await queryLinearIdentity(`Bearer ${tokenResponse.access_token}`); + if (!identity) { + throw new CliError( + 'Linear viewer query rejected the access token. This is unexpected — token was just issued. ' + + 'Re-run `bgagent linear add-workspace` if Linear\'s API is recovering from a transient outage.', + ); + } + console.log(` ✓ (${identity.organization.name ?? identity.organization.urlKey ?? identity.organization.id})`); + + if (identity.organization.urlKey && identity.organization.urlKey !== slug) { + throw new CliError( + `Slug '${slug}' does not match Linear's urlKey '${identity.organization.urlKey}' for the authorized workspace. ` + + 'Re-run with the correct slug — using the wrong slug would shadow the secret name and produce a confusing registry row.', + ); + } + + // ─── Refuse re-install of an already-onboarded workspace ─────── + // Different from `setup`, which is intentionally idempotent: the + // explicit add-workspace verb implies "new workspace", and silently + // overwriting a registry row could mask a wrong-account login. + const existing = await ddb.send(new ScanCommand({ + TableName: workspaceRegistryTable!, + FilterExpression: 'linear_workspace_id = :id', + ExpressionAttributeValues: { ':id': identity.organization.id }, + Limit: 1, + })); + if (existing.Items && existing.Items.length > 0) { + throw new CliError( + `Workspace '${slug}' (${identity.organization.id}) is already in the registry. ` + + 'Use `bgagent linear setup` to re-authorize an existing workspace, or remove the registry row manually before retrying.', + ); + } + + // ─── Persist token to per-workspace SM ───────────────────────── + process.stdout.write(' → Storing OAuth token...'); + const now = new Date().toISOString(); + const stored: StoredLinearOauthToken = { + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token ?? '', + expires_at: computeExpiresAt(tokenResponse.expires_in), + scope: tokenResponse.scope, + client_id: clientId, + client_secret: clientSecret, + workspace_id: identity.organization.id, + workspace_slug: slug, + installed_at: now, + updated_at: now, + installed_by_platform_user_id: cognitoSub, + }; + if (!stored.refresh_token) { + throw new CliError( + 'Linear did not return a refresh_token. The integration cannot self-renew tokens; ' + + 're-check that the Linear OAuth app permits refresh-token grants.', + ); + } + const secretName = linearOauthSecretName(slug); + const oauthSecretArn = await upsertOauthSecret(sm, secretName, stored, slug); + console.log(` ✓ (${secretName})`); + + // ─── Persist registry + user-mapping rows ────────────────────── + await ddb.send(new PutCommand({ + TableName: workspaceRegistryTable!, + Item: { + linear_workspace_id: identity.organization.id, + workspace_slug: slug, + oauth_secret_arn: oauthSecretArn, + installed_by_platform_user_id: cognitoSub, + installed_at: now, + updated_at: now, + status: 'active', + }, + })); + console.log(' ✓ Recorded workspace in registry'); + + await ddb.send(new PutCommand({ + TableName: userMappingTable!, + Item: { + linear_identity: `${identity.organization.id}#${identity.viewer.id}`, + platform_user_id: cognitoSub, + linear_workspace_id: identity.organization.id, + linear_user_id: identity.viewer.id, + linked_at: now, + status: 'active', + link_method: 'add_workspace_oauth', + }, + })); + const adminLabel = identity.viewer.name ?? identity.viewer.email ?? identity.viewer.id; + console.log(` ✓ Linked Linear user ${adminLabel} → platform user`); + + // ─── Done ────────────────────────────────────────────────────── + console.log(); + console.log('✅ Workspace added.'); + console.log(); + console.log('Note: webhook signing secret was NOT prompted — it is shared across all'); + console.log('workspaces installed against the same Linear OAuth app. If this is the first'); + console.log('time installing in a new Linear team that has its own OAuth app, run'); + console.log('`bgagent linear setup` instead so the webhook signing secret gets configured.'); + console.log(); + console.log('Next: onboard a project from this workspace:'); + console.log(' bgagent linear onboard-project --repo owner/repo'); + }), + ); + linear.addCommand( new Command('onboard-project') .description('Map a Linear project to a GitHub repository (admin IAM required)') diff --git a/cli/test/commands/linear.test.ts b/cli/test/commands/linear.test.ts index ac480b55..aa9f53d0 100644 --- a/cli/test/commands/linear.test.ts +++ b/cli/test/commands/linear.test.ts @@ -17,9 +17,10 @@ * SOFTWARE. */ -import { PutCommand } from '@aws-sdk/lib-dynamodb'; +import { PutCommand, ScanCommand } from '@aws-sdk/lib-dynamodb'; import { autoLinkTokenOwner, + findReusableOauthAppCredentials, isWebhookSecretConfigured, renderLinearAppTemplate, } from '../../src/commands/linear'; @@ -234,3 +235,87 @@ describe('isWebhookSecretConfigured', () => { expect(await isWebhookSecretConfigured(mockClient, 'arn:secret')).toBe(false); }); }); + +describe('findReusableOauthAppCredentials', () => { + // The helper is the linchpin of `bgagent linear add-workspace`: if it + // returns the wrong (or no) values, the operator either gets a confusing + // re-prompt or — worse — installs a workspace against an OAuth app that + // doesn't match the existing workspaces' refresh-token rotations. + const smSend = jest.fn(); + const smClient = { send: smSend } as unknown as Parameters[1]; + + beforeEach(() => { + ddbSend.mockReset(); + smSend.mockReset(); + }); + + test('returns null when registry has no active rows', async () => { + ddbSend.mockResolvedValueOnce({ Items: [] }); + const ddbClient = { send: ddbSend } as unknown as Parameters[0]; + expect(await findReusableOauthAppCredentials(ddbClient, smClient, 'TestRegistry')).toBeNull(); + // Verify the scan filter is the active-status one, not a full table scan. + const scanCmd = ddbSend.mock.calls[0][0] as ScanCommand; + expect(scanCmd.input.FilterExpression).toBe('#status = :active'); + expect(scanCmd.input.Limit).toBe(1); + }); + + test('returns credentials from the first active workspace', async () => { + ddbSend.mockResolvedValueOnce({ + Items: [{ + workspace_slug: 'acme', + oauth_secret_arn: 'arn:secret:acme', + status: 'active', + }], + }); + smSend.mockResolvedValueOnce({ + SecretString: JSON.stringify({ + access_token: 'lin_at', + refresh_token: 'lin_rt', + client_id: 'cid-acme', + client_secret: 'csec-acme', + workspace_id: 'ws-1', + workspace_slug: 'acme', + }), + }); + const ddbClient = { send: ddbSend } as unknown as Parameters[0]; + const result = await findReusableOauthAppCredentials(ddbClient, smClient, 'TestRegistry'); + expect(result).toEqual({ + clientId: 'cid-acme', + clientSecret: 'csec-acme', + sourceSlug: 'acme', + }); + }); + + test('returns null when the secret is missing client_id/client_secret', async () => { + // Phase 2.0a (parked) secrets only stored access_token + refresh_token — + // a half-migrated install would hit this path. Returning null forces the + // operator to pass --client-id explicitly rather than silently using + // empty strings. + ddbSend.mockResolvedValueOnce({ + Items: [{ workspace_slug: 'old', oauth_secret_arn: 'arn:secret:old', status: 'active' }], + }); + smSend.mockResolvedValueOnce({ + SecretString: JSON.stringify({ access_token: 'a', refresh_token: 'r' }), + }); + const ddbClient = { send: ddbSend } as unknown as Parameters[0]; + expect(await findReusableOauthAppCredentials(ddbClient, smClient, 'TestRegistry')).toBeNull(); + }); + + test('returns null on corrupted SecretString JSON', async () => { + ddbSend.mockResolvedValueOnce({ + Items: [{ workspace_slug: 's', oauth_secret_arn: 'arn:s', status: 'active' }], + }); + smSend.mockResolvedValueOnce({ SecretString: '{not valid json' }); + const ddbClient = { send: ddbSend } as unknown as Parameters[0]; + expect(await findReusableOauthAppCredentials(ddbClient, smClient, 'TestRegistry')).toBeNull(); + }); + + test('returns null when SecretString is missing', async () => { + ddbSend.mockResolvedValueOnce({ + Items: [{ workspace_slug: 's', oauth_secret_arn: 'arn:s', status: 'active' }], + }); + smSend.mockResolvedValueOnce({}); + const ddbClient = { send: ddbSend } as unknown as Parameters[0]; + expect(await findReusableOauthAppCredentials(ddbClient, smClient, 'TestRegistry')).toBeNull(); + }); +}); From 00d01bea723c742cbba2139f2bade29d6a24fe24 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 19:39:03 -0400 Subject: [PATCH 016/190] docs(linear): rewrite setup guide for shipped 2.0b-O2 flow + per-workspace OAuth-app option MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous version described the parked Phase 2.0a flow (AgentCore Identity credential providers, oauth-register-workspace command, AWS- hosted callback URL, https://localhost:8443 with self-signed cert). None of that runs in the shipped 2.0b-O2 codebase — it's all per- workspace Secrets Manager + plain HTTP localhost:8080 callback. Changes: - 'How it works' rewritten against the SM-direct flow, with a callout noting Phase 2.0a is parked - Step 1 (oauth-register-workspace) deleted — not a real command - Step 2 (Linear OAuth app) updated to point at localhost:8080/oauth/ callback (the actual callback URL); flagged that app-template's printed value is still the parked-flow placeholder - Step 4 (setup) rewritten to describe the PKCE → localhost:8080 → code exchange → SM upsert dance that actually ships - Step 5 (webhook signing secret) folded into setup's interactive prompt as Step 3, matching how the wizard actually works - Steps 6-8 renumbered to 4-6 - 'Adding additional Linear workspaces' expanded with the public- vs-per-workspace OAuth-app trade-off and the Option B path (--client-id/--client-secret overrides) for keeping apps private — this is the wrinkle that bit during demo-abca install where maguireb's private app rejected cross-workspace authorization - Troubleshooting + quotas sections updated to reference SM secrets and the refresh+race-recovery flow rather than AgentCore Identity - Stale Step 7 cross-references updated to Step 5 Followup task: update bgagent linear app-template to print http://localhost:8080/oauth/callback as the default callback (today it prints a placeholder for the parked AWS-hosted-callback flow). --- docs/guides/LINEAR_SETUP_GUIDE.md | 142 +++++++++--------- .../content/docs/using/Linear-setup-guide.md | 142 +++++++++--------- 2 files changed, 148 insertions(+), 136 deletions(-) diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index 01c503c7..c84cc4af 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -2,7 +2,7 @@ This guide walks through setting up the ABCA Linear integration. Once configured, applying the `bgagent` label to an issue in a mapped Linear project triggers an autonomous task. The agent posts progress comments back on the Linear issue as it works. -> **Phase 2.0b** — ABCA now authenticates to Linear via OAuth (`actor=app`) instead of a personal API key. One OAuth app per ABCA deployment, one credential provider per Linear workspace. Personal API keys are no longer supported (see [Migration from 2.0a (PAK) to 2.0b (OAuth)](#migration-from-20a-pak-to-20b-oauth) below). +> **Phase 2.0b** — ABCA now authenticates to Linear via OAuth (`actor=app`) instead of a personal API key. One per-workspace OAuth secret in AWS Secrets Manager, one OAuth app (or one per workspace, your choice). Personal API keys are no longer supported (see [Migration from 2.0a (PAK) to 2.0b (OAuth)](#migration-from-20a-pak-to-20b-oauth) below). ## Prerequisites @@ -14,37 +14,22 @@ This guide walks through setting up the ABCA Linear integration. Once configured ## How it works -1. A Linear-workspace admin creates a Linear OAuth app, registers it as an AgentCore Identity credential provider, and authorizes it on the workspace via `bgagent linear setup`. The workspace's OAuth token lives in the AgentCore Identity vault, keyed on `userId=linear-workspace-`. **One install per workspace, used by all teammates** — this matches the v1 personal-API-key semantics. +1. A Linear-workspace admin creates a Linear OAuth app and authorizes it on the workspace via `bgagent linear setup`. The workspace's OAuth token (access + refresh) is stored in a per-workspace Secrets Manager secret named `bgagent-linear-oauth-`. **One install per workspace, used by all teammates** — this matches the v1 personal-API-key semantics. 2. A user adds the `bgagent` label (configurable per project) to a Linear issue. 3. Linear fires a webhook to `POST /v1/linear/webhook`. ABCA verifies the HMAC signature and dedups retries. -4. A processor Lambda looks up the Linear `organizationId` in `LinearWorkspaceRegistryTable` to find the credential provider name, retrieves the workspace's OAuth token via AgentCore Identity, then resolves the project → repo mapping and creates a task with `channel_source: 'linear'`. -5. The agent clones the repo, writes `.mcp.json` with Linear's hosted MCP server, and runs. It uses `mcp__linear-server__save_comment` / `mcp__linear-server__update_issue` to post updates as `bgagent[bot]` (the OAuth app's identity). +4. A processor Lambda looks up the Linear `organizationId` in `LinearWorkspaceRegistryTable` to find that workspace's OAuth secret ARN, reads the secret, refreshes the access token if expiring, then resolves the project → repo mapping and creates a task with `channel_source: 'linear'`. +5. The agent clones the repo, writes `.mcp.json` with Linear's hosted MCP server using the freshly-resolved access token, and runs. It uses `mcp__linear-server__save_comment` / `mcp__linear-server__update_issue` to post updates as `bgagent[bot]` (the OAuth app's identity). 6. The agent opens a PR on GitHub and adds a final comment to the Linear issue with the PR link. **Trigger**: only Linear issues with the configured label in a mapped project create tasks. Issues without the label, or in unmapped projects, are ignored. Label removal does not cancel a running task. -**Multi-workspace**: a single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own AgentCore credential provider via `bgagent linear add-workspace`. +**Multi-workspace**: a single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own per-workspace OAuth secret via `bgagent linear add-workspace`. See [Adding additional Linear workspaces](#adding-additional-linear-workspaces) for details, including the per-workspace OAuth-app option needed when Linear apps are kept private. -## Step-by-step setup - -### Step 1: Create the AgentCore credential provider - -The credential provider is an AWS-side OAuth2 client registration. It generates the **AWS-hosted callback URL** that Linear will redirect the browser to during consent — without this URL, you can't complete Step 2. - -```bash -bgagent linear oauth-register-workspace -``` - -Where `` is the Linear `urlKey` of the workspace (e.g. `acme` from `https://linear.app/acme/...`). The command prompts for the Linear OAuth app's `clientId` and `clientSecret` — you don't have these yet, so first create the Linear OAuth app in Step 2 below, then come back and finish this step. Either order works; just pair them. - -The command: -- Calls `aws bedrock-agentcore-control create-oauth2-credential-provider` with `credentialProviderVendor='CustomOauth2'` (Linear is not a built-in vendor, so the command supplies an explicit `authorizationServerMetadata` block — Linear has no `.well-known/openid-configuration`). -- Prints the AWS-hosted callback URL you'll paste into Linear's app form. -- Records the provider name (`linear-oauth-`) for `bgagent linear setup` to use later. +> **Phase 2.0a (parked).** The previous design routed OAuth through AgentCore Identity credential providers. That path is parked — Phase 2.0b-O2 (shipped) reads Secrets Manager directly because AgentCore Identity's `USER_FEDERATION` flow has an open service-side bug. The setup steps below describe the shipped flow only. -> **Why AWS hosts the callback.** Earlier ABCA designs (and most third-party docs at the time of writing) assumed the integrator hosted their own callback service. AgentCore Identity actually proxies the callback itself; the URL it surfaces in `create-oauth2-credential-provider` response (`callbackUrl`) is what Linear redirects to, **not** an URL you control. The `resourceOauth2ReturnUrl` you pass to `get_resource_oauth2_token` is just where AWS sends the **browser** after AWS finishes the code-exchange — typically a localhost URL that `bgagent linear setup` listens on for that one redirect. +## Step-by-step setup -### Step 2: Create the Linear OAuth app +### Step 1: Create the Linear OAuth app Run: @@ -52,58 +37,52 @@ Run: bgagent linear app-template ``` -This prints the exact field values to paste into Linear's OAuth app form. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) and fill in the fields the template lists. Critical fields (each gates the `actor=app` agent flow — without them Linear surfaces a misleading "Invalid redirect_uri" error): +This prints the exact field values to paste into Linear's OAuth app form. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) (make sure you're signed into the workspace where you want the app to live — use Linear's workspace switcher in the sidebar if needed) and fill in the fields the template lists. Critical fields (each gates the `actor=app` agent flow — without them Linear surfaces a misleading "Invalid redirect_uri" error): - **GitHub username**: must end with the literal `[bot]` suffix (e.g., `bgagent[bot]`) - **Webhooks**: toggle ON (the URL value can be a placeholder; we don't subscribe to events for the OAuth flow itself) -- **Callback URLs**: paste the AWS-hosted URL from Step 1 on a single line. Wildcards are not accepted; if you have multiple environments, register each URL fully. - -If you ran Step 1 first, pass the AWS callback URL to the template so it's filled in: +- **Callback URLs**: `http://localhost:8080/oauth/callback` — the localhost server `bgagent linear setup` listens on for the redirect. Wildcards are not accepted; if you serve setup from multiple machines, register each callback URL fully. +- **Public**: leave OFF unless you plan to install this app in multiple Linear workspaces — see [Adding additional Linear workspaces](#adding-additional-linear-workspaces) for the trade-offs. -```bash -bgagent linear app-template --aws-callback-url "" -``` +> **Note.** The `app-template` command currently prints a placeholder for the AWS-hosted callback URL referencing the parked Phase 2.0a flow. The actual callback for the shipped Phase 2.0b-O2 flow is `http://localhost:8080/oauth/callback`. The template will be updated to print this value once the parked path is removed; for now, override the placeholder when you paste into Linear. Click **Save**, then copy the **Client ID** and **Client Secret** from the app's detail page. -### Step 3: Finish Step 1 — paste Linear secrets - -Return to the terminal where Step 1 is paused at the `Client ID:` prompt and paste the values you copied from Linear. The credential provider is now wired up. - -### Step 4: Authorize via OAuth +### Step 2: Authorize via OAuth ```bash -bgagent linear setup +bgagent linear setup ``` +Where `` is the Linear `urlKey` of the workspace (e.g. `acme` from `https://linear.app/acme/...`). + The wizard: -1. Looks up the credential provider you registered in Step 1. -2. Starts an ephemeral HTTPS server on `localhost:8443` with a self-signed cert. **Your browser will warn about the cert** — click through, it's local-only. -3. Calls `get_resource_oauth2_token` with `customParameters={'actor': 'app'}` and opens the returned `authorizationUrl` in your default browser. -4. You authorize the OAuth app on the Linear consent screen. -5. AWS handles the code-exchange with Linear behind the scenes, then redirects your browser to `https://localhost:8443/oauth/callback?session_id=...`. -6. The wizard captures the `session_id`, polls for the access token (5s/600s timeout), then queries Linear's `viewer { id, organization { id, urlKey } }` to record workspace metadata in `LinearWorkspaceRegistryTable`. +1. Prompts for the **Client ID** and **Client Secret** you copied at the end of Step 1 (or pass them via `--client-id` / `--client-secret`). +2. Generates a PKCE code verifier + challenge and starts an ephemeral HTTP server on `localhost:8080` to listen for the callback. +3. Opens Linear's authorization URL in your browser. **Make sure your browser is currently signed into the right workspace** (use Linear's workspace switcher if needed); this is the workspace the app is being installed in. +4. You authorize the OAuth app on the Linear consent screen — Linear redirects to `http://localhost:8080/oauth/callback?code=...&state=...`. +5. The wizard exchanges the code for an `access_token` + `refresh_token`, queries Linear's `viewer { id, organization { id, urlKey } }`, and: + - Creates `bgagent-linear-oauth-` in Secrets Manager with the full token bundle (access, refresh, expires_at, scope, client_id, client_secret, workspace metadata). + - Writes a row into `LinearWorkspaceRegistryTable` with `(linear_workspace_id, workspace_slug, oauth_secret_arn, status='active')`. + - Auto-links you in `LinearUserMappingTable` so tasks you trigger via Linear get attributed to your Cognito user. +6. Then prompts for the **webhook signing secret** — see Step 3 below for where to find it. -The OAuth token is stored in the AWS-managed token vault under `userId=linear-workspace-`. **All teammates' Linear-triggered tasks share this single token** — that's by design (matches the v1 PAK semantics, just with a revocable / scoped credential and audit trail). +> **Where the OAuth token lives.** Stored in Secrets Manager at `bgagent-linear-oauth-`, with `client_id` + `client_secret` co-located in the same secret so Lambda-side refresh works without per-Lambda env vars. Lambdas refresh on demand and write the rotated token back; the agent runtime has read-only access (S1 hardening — untrusted repo code can't overwrite tokens). -### Step 5: Configure the Linear webhook +### Step 3: Configure the Linear webhook -In [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+**: +While `bgagent linear setup` is paused at the `Webhook signing secret:` prompt, open [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+**: -- **URL**: paste the URL `bgagent linear setup` printed at the end of Step 4 (looks like `https://.execute-api..amazonaws.com/v1/linear/webhook`) +- **URL**: `https://.execute-api..amazonaws.com/v1/linear/webhook` — find this in the CloudFormation stack's `ApiUrl` output, or look up your API Gateway in the AWS console - **Resource types**: check **Issues** only - **Team**: whichever team owns the projects you'll map to ABCA (or all teams) -Save, then open the webhook's detail page and copy the **signing secret**. Run: +Save, then open the webhook's detail page and copy the **signing secret** (starts with `lin_wh_`). Paste it back into the terminal where setup is paused. The wizard stores it in `LinearWebhookSecret` (one secret per ABCA stack — shared across all installed workspaces). -```bash -bgagent linear setup --webhook-secret -``` +> **Re-running setup later** skips the webhook prompt if the signing secret is already configured. Pass `--rotate-webhook-secret` to re-prompt (e.g. after rotating the secret in Linear). -This stores the secret in `LinearWebhookSecret`. (Webhook signing is independent of OAuth — it's how Linear authenticates inbound calls to your API Gateway, separate from how the agent authenticates outbound calls to Linear.) - -### Step 6: Onboard a Linear project +### Step 4: Onboard a Linear project Map a Linear project UUID to the GitHub repo you want tasks routed to: @@ -128,7 +107,7 @@ bgagent linear list-projects Copy the `id` of the project you want to onboard. `onboard-project` validates the UUID format and will reject the truncated slug version with a pointer back to this command. -### Step 7: Link your Linear account (optional but recommended) +### Step 5: Link your Linear account (optional but recommended) ABCA needs to know which platform user a Linear actor maps to so triggered tasks are attributed correctly (concurrency caps, billing, `bgagent list`). @@ -153,27 +132,53 @@ ABCA needs to know which platform user a Linear actor maps to so triggered tasks - **Self-service (planned, v2.x):** a comment-driven `@bgagent link` flow that exchanges a code for a row write — `bgagent linear link ` exists in v1 but is non-functional until the Linear-side code generator ships. -### Step 8: Test it +### Step 6: Test it Add the `bgagent` label to a Linear issue in a mapped project. Within a few seconds: - The Linear webhook Lambda logs an `INFO` entry and invokes the processor. -- The processor looks up `LinearWorkspaceRegistryTable` by the webhook's `organizationId`, retrieves the workspace's OAuth token via AgentCore Identity, and creates a task in `TaskTable` with `channel_source: 'linear'`. +- The processor looks up `LinearWorkspaceRegistryTable` by the webhook's `organizationId`, reads the workspace's OAuth secret from Secrets Manager (refreshing the access token if expiring), and creates a task in `TaskTable` with `channel_source: 'linear'`. - The agent container starts, clones the repo, and posts a `🤖 Starting on this issue…` comment as `bgagent[bot]`. - When the agent opens a PR, another comment appears with the PR link and the issue transitions to `In Review` (if that state exists). - On completion or failure, a final status comment is posted. ## Adding additional Linear workspaces -A single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own credential provider and OAuth install: +A single ABCA deployment can serve multiple Linear workspaces. Once you've completed initial `bgagent linear setup` for one workspace, additional workspaces use the lighter `add-workspace` command: ```bash bgagent linear add-workspace ``` -This re-runs Steps 1, 2, and 4 of the setup (asks for a new clientId/secret pair, creates a `linear-oauth-` provider, runs the OAuth dance against the new workspace). You'll need to create a separate Linear OAuth app for each workspace — Linear apps are workspace-scoped at install time even though the same OAuth credentials *could* technically install in multiple workspaces. Per-workspace apps give cleaner revocation and per-workspace branding. +This: + +- Auto-detects the OAuth app's `client_id`/`client_secret` from any existing active workspace's per-workspace secret (no re-prompt) +- Runs the OAuth dance against the new workspace +- Creates `bgagent-linear-oauth-` in Secrets Manager and writes a registry row +- **Skips the webhook signing secret prompt** — the same signing secret covers all workspaces against the same ABCA receiver URL +- Refuses to silently overwrite an already-onboarded workspace's registry row (use `setup` to re-authorize an existing workspace) + +### One OAuth app for all workspaces vs. one per workspace + +Linear OAuth apps are **workspace-scoped at install time**: + +- A **private** Linear OAuth app (default) can only be authorized from the workspace that created it. Trying to install it in a second workspace returns `Could not find OAuth client with clientId `. +- A **public** Linear OAuth app can be authorized from any workspace by anyone with the install URL. The client_secret is still yours; "public" only means "anyone can run the consent flow." For a self-hosted ABCA install this is usually fine. + +Pick one of: + +**Option A: Single shared OAuth app (recommended for personal demos and single-org setups).** In your initial workspace's Linear settings, edit the OAuth app and toggle **Public: ON**. Then `bgagent linear add-workspace ` works without `--client-id`. Cleanest UX, single point of revocation. + +**Option B: Separate OAuth app per workspace (recommended for multi-org / production setups).** Create a new OAuth app in each new workspace's Linear settings (Step 1 above), then pass the new credentials explicitly: + +```bash +bgagent linear add-workspace \ + --client-id --client-secret +``` + +Per-workspace apps give cleaner revocation, per-workspace branding, and isolation if one workspace's credentials leak. Each new app needs its own callback URL (`http://localhost:8080/oauth/callback`) and its own `bgagent[bot]` GitHub username. -The 50-credential-provider-per-account quota in AgentCore is the practical ceiling for multi-tenant deployments. +There's no AWS-side ceiling on the number of installable workspaces — each is just an SM secret + DDB row. Practical limits are Linear's API rate limits and per-workspace operator overhead. ## Usage @@ -201,7 +206,7 @@ Use `bgagent cancel `. Removing the Linear label does not cancel a runn ### "Linear actor has no linked platform user — skipping task creation" -The Linear user who applied the label hasn't been mapped to a Cognito user. See [Step 7](#step-7-link-your-linear-account-optional-but-recommended). +The Linear user who applied the label hasn't been mapped to a Cognito user. See [Step 5](#step-5-link-your-linear-account-optional-but-recommended). ### "Invalid redirect_uri parameter for the application" during Step 4 @@ -215,10 +220,11 @@ Re-run `bgagent linear setup` after fixing. ### Agent doesn't post comments to Linear -1. Verify the OAuth credential provider exists: `aws bedrock-agentcore-control list-oauth2-credential-providers --region ` — look for `linear-oauth-`. -2. Verify the workspace is registered: scan `LinearWorkspaceRegistryTable`. +1. Verify the per-workspace OAuth secret exists: `aws secretsmanager describe-secret --secret-id bgagent-linear-oauth- --region `. +2. Verify the workspace is registered: scan `LinearWorkspaceRegistryTable` and confirm the row's `oauth_secret_arn` matches the secret from step 1 and `status = 'active'`. 3. Check the agent container logs for `Linear MCP configured at …` — absence means `channel_source` wasn't set on the task or the workspace lookup failed. -4. Check for `WARN linear_reactions: HTTP 401 from Linear` in CloudWatch — usually means the OAuth token in the vault has been revoked from the Linear side. Re-run `bgagent linear setup` to re-authorize. +4. Check for `WARN linear_reactions: HTTP 401 from Linear` in CloudWatch — usually means the refresh token has been revoked from the Linear side, or the workspace admin uninstalled the app. Re-run `bgagent linear setup ` to re-authorize. +5. Check for `resolve_linear_api_token: invalid_grant` in CloudWatch — Linear permanently rejected the refresh token (rotation race or revocation). Re-run `bgagent linear setup ` to issue a new refresh token. ### Webhook signature verification fails repeatedly @@ -281,19 +287,19 @@ Linear's API rate limits per OAuth-installed app, per workspace: A typical task makes ~10 Linear API calls (one starting comment, one PR comment, one state transition, one final comment), nowhere near the ceiling. Heavy users should monitor the `X-RateLimit-Requests-Remaining` header in agent logs. -AgentCore Identity quotas worth knowing: +AWS quotas worth knowing: | Metric | Limit | |--------|-------| -| OAuth2 credential providers per account-region | 50 | -| Workload identities per account-region | (check Service Quotas console) | +| Secrets Manager secrets per region | 500,000 (soft) | +| Secrets Manager `GetSecretValue` ops/sec | 10,000 | -Token refresh: Linear access tokens expire in 24h (since April 2026). AgentCore Identity auto-refreshes via the stored refresh token; the agent's `get_resource_oauth2_token` call returns a fresh token transparently. +Token refresh: Linear access tokens expire in 24h (since April 2026). The webhook processor and orchestrator auto-refresh via the stored `refresh_token` and write the rotated token back to Secrets Manager. Race recovery: if Linear returns `invalid_grant` (a concurrent caller already refreshed), the resolver re-reads the secret and uses the freshly-rotated token without a second `/oauth/token` POST. ## What's out of scope in v1.x - **Comment-driven task triggers**: only labels trigger tasks. Comment commands (e.g. `@bgagent fix this`) are v2+. -- **Self-service user linking**: see Step 7 — admins must insert mapping rows manually until v2.x ships the `@bgagent link` comment flow. +- **Self-service user linking**: see Step 5 — admins must insert mapping rows manually until v2.x ships the `@bgagent link` comment flow. - **Attachments**: tickets are text-only. Linear attachments (mockups, screenshots) are planned via S3 pre-fetch. - **Per-issue status polling**: use `bgagent status` or watch the Linear issue comments. diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index 4beb4c2f..b9e1ce4a 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -6,7 +6,7 @@ title: Linear setup guide This guide walks through setting up the ABCA Linear integration. Once configured, applying the `bgagent` label to an issue in a mapped Linear project triggers an autonomous task. The agent posts progress comments back on the Linear issue as it works. -> **Phase 2.0b** — ABCA now authenticates to Linear via OAuth (`actor=app`) instead of a personal API key. One OAuth app per ABCA deployment, one credential provider per Linear workspace. Personal API keys are no longer supported (see [Migration from 2.0a (PAK) to 2.0b (OAuth)](#migration-from-20a-pak-to-20b-oauth) below). +> **Phase 2.0b** — ABCA now authenticates to Linear via OAuth (`actor=app`) instead of a personal API key. One per-workspace OAuth secret in AWS Secrets Manager, one OAuth app (or one per workspace, your choice). Personal API keys are no longer supported (see [Migration from 2.0a (PAK) to 2.0b (OAuth)](#migration-from-20a-pak-to-20b-oauth) below). ## Prerequisites @@ -18,37 +18,22 @@ This guide walks through setting up the ABCA Linear integration. Once configured ## How it works -1. A Linear-workspace admin creates a Linear OAuth app, registers it as an AgentCore Identity credential provider, and authorizes it on the workspace via `bgagent linear setup`. The workspace's OAuth token lives in the AgentCore Identity vault, keyed on `userId=linear-workspace-`. **One install per workspace, used by all teammates** — this matches the v1 personal-API-key semantics. +1. A Linear-workspace admin creates a Linear OAuth app and authorizes it on the workspace via `bgagent linear setup`. The workspace's OAuth token (access + refresh) is stored in a per-workspace Secrets Manager secret named `bgagent-linear-oauth-`. **One install per workspace, used by all teammates** — this matches the v1 personal-API-key semantics. 2. A user adds the `bgagent` label (configurable per project) to a Linear issue. 3. Linear fires a webhook to `POST /v1/linear/webhook`. ABCA verifies the HMAC signature and dedups retries. -4. A processor Lambda looks up the Linear `organizationId` in `LinearWorkspaceRegistryTable` to find the credential provider name, retrieves the workspace's OAuth token via AgentCore Identity, then resolves the project → repo mapping and creates a task with `channel_source: 'linear'`. -5. The agent clones the repo, writes `.mcp.json` with Linear's hosted MCP server, and runs. It uses `mcp__linear-server__save_comment` / `mcp__linear-server__update_issue` to post updates as `bgagent[bot]` (the OAuth app's identity). +4. A processor Lambda looks up the Linear `organizationId` in `LinearWorkspaceRegistryTable` to find that workspace's OAuth secret ARN, reads the secret, refreshes the access token if expiring, then resolves the project → repo mapping and creates a task with `channel_source: 'linear'`. +5. The agent clones the repo, writes `.mcp.json` with Linear's hosted MCP server using the freshly-resolved access token, and runs. It uses `mcp__linear-server__save_comment` / `mcp__linear-server__update_issue` to post updates as `bgagent[bot]` (the OAuth app's identity). 6. The agent opens a PR on GitHub and adds a final comment to the Linear issue with the PR link. **Trigger**: only Linear issues with the configured label in a mapped project create tasks. Issues without the label, or in unmapped projects, are ignored. Label removal does not cancel a running task. -**Multi-workspace**: a single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own AgentCore credential provider via `bgagent linear add-workspace`. +**Multi-workspace**: a single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own per-workspace OAuth secret via `bgagent linear add-workspace`. See [Adding additional Linear workspaces](#adding-additional-linear-workspaces) for details, including the per-workspace OAuth-app option needed when Linear apps are kept private. -## Step-by-step setup - -### Step 1: Create the AgentCore credential provider - -The credential provider is an AWS-side OAuth2 client registration. It generates the **AWS-hosted callback URL** that Linear will redirect the browser to during consent — without this URL, you can't complete Step 2. - -```bash -bgagent linear oauth-register-workspace -``` - -Where `` is the Linear `urlKey` of the workspace (e.g. `acme` from `https://linear.app/acme/...`). The command prompts for the Linear OAuth app's `clientId` and `clientSecret` — you don't have these yet, so first create the Linear OAuth app in Step 2 below, then come back and finish this step. Either order works; just pair them. - -The command: -- Calls `aws bedrock-agentcore-control create-oauth2-credential-provider` with `credentialProviderVendor='CustomOauth2'` (Linear is not a built-in vendor, so the command supplies an explicit `authorizationServerMetadata` block — Linear has no `.well-known/openid-configuration`). -- Prints the AWS-hosted callback URL you'll paste into Linear's app form. -- Records the provider name (`linear-oauth-`) for `bgagent linear setup` to use later. +> **Phase 2.0a (parked).** The previous design routed OAuth through AgentCore Identity credential providers. That path is parked — Phase 2.0b-O2 (shipped) reads Secrets Manager directly because AgentCore Identity's `USER_FEDERATION` flow has an open service-side bug. The setup steps below describe the shipped flow only. -> **Why AWS hosts the callback.** Earlier ABCA designs (and most third-party docs at the time of writing) assumed the integrator hosted their own callback service. AgentCore Identity actually proxies the callback itself; the URL it surfaces in `create-oauth2-credential-provider` response (`callbackUrl`) is what Linear redirects to, **not** an URL you control. The `resourceOauth2ReturnUrl` you pass to `get_resource_oauth2_token` is just where AWS sends the **browser** after AWS finishes the code-exchange — typically a localhost URL that `bgagent linear setup` listens on for that one redirect. +## Step-by-step setup -### Step 2: Create the Linear OAuth app +### Step 1: Create the Linear OAuth app Run: @@ -56,58 +41,52 @@ Run: bgagent linear app-template ``` -This prints the exact field values to paste into Linear's OAuth app form. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) and fill in the fields the template lists. Critical fields (each gates the `actor=app` agent flow — without them Linear surfaces a misleading "Invalid redirect_uri" error): +This prints the exact field values to paste into Linear's OAuth app form. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) (make sure you're signed into the workspace where you want the app to live — use Linear's workspace switcher in the sidebar if needed) and fill in the fields the template lists. Critical fields (each gates the `actor=app` agent flow — without them Linear surfaces a misleading "Invalid redirect_uri" error): - **GitHub username**: must end with the literal `[bot]` suffix (e.g., `bgagent[bot]`) - **Webhooks**: toggle ON (the URL value can be a placeholder; we don't subscribe to events for the OAuth flow itself) -- **Callback URLs**: paste the AWS-hosted URL from Step 1 on a single line. Wildcards are not accepted; if you have multiple environments, register each URL fully. - -If you ran Step 1 first, pass the AWS callback URL to the template so it's filled in: +- **Callback URLs**: `http://localhost:8080/oauth/callback` — the localhost server `bgagent linear setup` listens on for the redirect. Wildcards are not accepted; if you serve setup from multiple machines, register each callback URL fully. +- **Public**: leave OFF unless you plan to install this app in multiple Linear workspaces — see [Adding additional Linear workspaces](#adding-additional-linear-workspaces) for the trade-offs. -```bash -bgagent linear app-template --aws-callback-url "" -``` +> **Note.** The `app-template` command currently prints a placeholder for the AWS-hosted callback URL referencing the parked Phase 2.0a flow. The actual callback for the shipped Phase 2.0b-O2 flow is `http://localhost:8080/oauth/callback`. The template will be updated to print this value once the parked path is removed; for now, override the placeholder when you paste into Linear. Click **Save**, then copy the **Client ID** and **Client Secret** from the app's detail page. -### Step 3: Finish Step 1 — paste Linear secrets - -Return to the terminal where Step 1 is paused at the `Client ID:` prompt and paste the values you copied from Linear. The credential provider is now wired up. - -### Step 4: Authorize via OAuth +### Step 2: Authorize via OAuth ```bash -bgagent linear setup +bgagent linear setup ``` +Where `` is the Linear `urlKey` of the workspace (e.g. `acme` from `https://linear.app/acme/...`). + The wizard: -1. Looks up the credential provider you registered in Step 1. -2. Starts an ephemeral HTTPS server on `localhost:8443` with a self-signed cert. **Your browser will warn about the cert** — click through, it's local-only. -3. Calls `get_resource_oauth2_token` with `customParameters={'actor': 'app'}` and opens the returned `authorizationUrl` in your default browser. -4. You authorize the OAuth app on the Linear consent screen. -5. AWS handles the code-exchange with Linear behind the scenes, then redirects your browser to `https://localhost:8443/oauth/callback?session_id=...`. -6. The wizard captures the `session_id`, polls for the access token (5s/600s timeout), then queries Linear's `viewer { id, organization { id, urlKey } }` to record workspace metadata in `LinearWorkspaceRegistryTable`. +1. Prompts for the **Client ID** and **Client Secret** you copied at the end of Step 1 (or pass them via `--client-id` / `--client-secret`). +2. Generates a PKCE code verifier + challenge and starts an ephemeral HTTP server on `localhost:8080` to listen for the callback. +3. Opens Linear's authorization URL in your browser. **Make sure your browser is currently signed into the right workspace** (use Linear's workspace switcher if needed); this is the workspace the app is being installed in. +4. You authorize the OAuth app on the Linear consent screen — Linear redirects to `http://localhost:8080/oauth/callback?code=...&state=...`. +5. The wizard exchanges the code for an `access_token` + `refresh_token`, queries Linear's `viewer { id, organization { id, urlKey } }`, and: + - Creates `bgagent-linear-oauth-` in Secrets Manager with the full token bundle (access, refresh, expires_at, scope, client_id, client_secret, workspace metadata). + - Writes a row into `LinearWorkspaceRegistryTable` with `(linear_workspace_id, workspace_slug, oauth_secret_arn, status='active')`. + - Auto-links you in `LinearUserMappingTable` so tasks you trigger via Linear get attributed to your Cognito user. +6. Then prompts for the **webhook signing secret** — see Step 3 below for where to find it. -The OAuth token is stored in the AWS-managed token vault under `userId=linear-workspace-`. **All teammates' Linear-triggered tasks share this single token** — that's by design (matches the v1 PAK semantics, just with a revocable / scoped credential and audit trail). +> **Where the OAuth token lives.** Stored in Secrets Manager at `bgagent-linear-oauth-`, with `client_id` + `client_secret` co-located in the same secret so Lambda-side refresh works without per-Lambda env vars. Lambdas refresh on demand and write the rotated token back; the agent runtime has read-only access (S1 hardening — untrusted repo code can't overwrite tokens). -### Step 5: Configure the Linear webhook +### Step 3: Configure the Linear webhook -In [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+**: +While `bgagent linear setup` is paused at the `Webhook signing secret:` prompt, open [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+**: -- **URL**: paste the URL `bgagent linear setup` printed at the end of Step 4 (looks like `https://.execute-api..amazonaws.com/v1/linear/webhook`) +- **URL**: `https://.execute-api..amazonaws.com/v1/linear/webhook` — find this in the CloudFormation stack's `ApiUrl` output, or look up your API Gateway in the AWS console - **Resource types**: check **Issues** only - **Team**: whichever team owns the projects you'll map to ABCA (or all teams) -Save, then open the webhook's detail page and copy the **signing secret**. Run: +Save, then open the webhook's detail page and copy the **signing secret** (starts with `lin_wh_`). Paste it back into the terminal where setup is paused. The wizard stores it in `LinearWebhookSecret` (one secret per ABCA stack — shared across all installed workspaces). -```bash -bgagent linear setup --webhook-secret -``` +> **Re-running setup later** skips the webhook prompt if the signing secret is already configured. Pass `--rotate-webhook-secret` to re-prompt (e.g. after rotating the secret in Linear). -This stores the secret in `LinearWebhookSecret`. (Webhook signing is independent of OAuth — it's how Linear authenticates inbound calls to your API Gateway, separate from how the agent authenticates outbound calls to Linear.) - -### Step 6: Onboard a Linear project +### Step 4: Onboard a Linear project Map a Linear project UUID to the GitHub repo you want tasks routed to: @@ -132,7 +111,7 @@ bgagent linear list-projects Copy the `id` of the project you want to onboard. `onboard-project` validates the UUID format and will reject the truncated slug version with a pointer back to this command. -### Step 7: Link your Linear account (optional but recommended) +### Step 5: Link your Linear account (optional but recommended) ABCA needs to know which platform user a Linear actor maps to so triggered tasks are attributed correctly (concurrency caps, billing, `bgagent list`). @@ -157,27 +136,53 @@ ABCA needs to know which platform user a Linear actor maps to so triggered tasks - **Self-service (planned, v2.x):** a comment-driven `@bgagent link` flow that exchanges a code for a row write — `bgagent linear link ` exists in v1 but is non-functional until the Linear-side code generator ships. -### Step 8: Test it +### Step 6: Test it Add the `bgagent` label to a Linear issue in a mapped project. Within a few seconds: - The Linear webhook Lambda logs an `INFO` entry and invokes the processor. -- The processor looks up `LinearWorkspaceRegistryTable` by the webhook's `organizationId`, retrieves the workspace's OAuth token via AgentCore Identity, and creates a task in `TaskTable` with `channel_source: 'linear'`. +- The processor looks up `LinearWorkspaceRegistryTable` by the webhook's `organizationId`, reads the workspace's OAuth secret from Secrets Manager (refreshing the access token if expiring), and creates a task in `TaskTable` with `channel_source: 'linear'`. - The agent container starts, clones the repo, and posts a `🤖 Starting on this issue…` comment as `bgagent[bot]`. - When the agent opens a PR, another comment appears with the PR link and the issue transitions to `In Review` (if that state exists). - On completion or failure, a final status comment is posted. ## Adding additional Linear workspaces -A single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own credential provider and OAuth install: +A single ABCA deployment can serve multiple Linear workspaces. Once you've completed initial `bgagent linear setup` for one workspace, additional workspaces use the lighter `add-workspace` command: ```bash bgagent linear add-workspace ``` -This re-runs Steps 1, 2, and 4 of the setup (asks for a new clientId/secret pair, creates a `linear-oauth-` provider, runs the OAuth dance against the new workspace). You'll need to create a separate Linear OAuth app for each workspace — Linear apps are workspace-scoped at install time even though the same OAuth credentials *could* technically install in multiple workspaces. Per-workspace apps give cleaner revocation and per-workspace branding. +This: + +- Auto-detects the OAuth app's `client_id`/`client_secret` from any existing active workspace's per-workspace secret (no re-prompt) +- Runs the OAuth dance against the new workspace +- Creates `bgagent-linear-oauth-` in Secrets Manager and writes a registry row +- **Skips the webhook signing secret prompt** — the same signing secret covers all workspaces against the same ABCA receiver URL +- Refuses to silently overwrite an already-onboarded workspace's registry row (use `setup` to re-authorize an existing workspace) + +### One OAuth app for all workspaces vs. one per workspace + +Linear OAuth apps are **workspace-scoped at install time**: + +- A **private** Linear OAuth app (default) can only be authorized from the workspace that created it. Trying to install it in a second workspace returns `Could not find OAuth client with clientId `. +- A **public** Linear OAuth app can be authorized from any workspace by anyone with the install URL. The client_secret is still yours; "public" only means "anyone can run the consent flow." For a self-hosted ABCA install this is usually fine. + +Pick one of: + +**Option A: Single shared OAuth app (recommended for personal demos and single-org setups).** In your initial workspace's Linear settings, edit the OAuth app and toggle **Public: ON**. Then `bgagent linear add-workspace ` works without `--client-id`. Cleanest UX, single point of revocation. + +**Option B: Separate OAuth app per workspace (recommended for multi-org / production setups).** Create a new OAuth app in each new workspace's Linear settings (Step 1 above), then pass the new credentials explicitly: + +```bash +bgagent linear add-workspace \ + --client-id --client-secret +``` + +Per-workspace apps give cleaner revocation, per-workspace branding, and isolation if one workspace's credentials leak. Each new app needs its own callback URL (`http://localhost:8080/oauth/callback`) and its own `bgagent[bot]` GitHub username. -The 50-credential-provider-per-account quota in AgentCore is the practical ceiling for multi-tenant deployments. +There's no AWS-side ceiling on the number of installable workspaces — each is just an SM secret + DDB row. Practical limits are Linear's API rate limits and per-workspace operator overhead. ## Usage @@ -205,7 +210,7 @@ Use `bgagent cancel `. Removing the Linear label does not cancel a runn ### "Linear actor has no linked platform user — skipping task creation" -The Linear user who applied the label hasn't been mapped to a Cognito user. See [Step 7](#step-7-link-your-linear-account-optional-but-recommended). +The Linear user who applied the label hasn't been mapped to a Cognito user. See [Step 5](#step-5-link-your-linear-account-optional-but-recommended). ### "Invalid redirect_uri parameter for the application" during Step 4 @@ -219,10 +224,11 @@ Re-run `bgagent linear setup` after fixing. ### Agent doesn't post comments to Linear -1. Verify the OAuth credential provider exists: `aws bedrock-agentcore-control list-oauth2-credential-providers --region ` — look for `linear-oauth-`. -2. Verify the workspace is registered: scan `LinearWorkspaceRegistryTable`. +1. Verify the per-workspace OAuth secret exists: `aws secretsmanager describe-secret --secret-id bgagent-linear-oauth- --region `. +2. Verify the workspace is registered: scan `LinearWorkspaceRegistryTable` and confirm the row's `oauth_secret_arn` matches the secret from step 1 and `status = 'active'`. 3. Check the agent container logs for `Linear MCP configured at …` — absence means `channel_source` wasn't set on the task or the workspace lookup failed. -4. Check for `WARN linear_reactions: HTTP 401 from Linear` in CloudWatch — usually means the OAuth token in the vault has been revoked from the Linear side. Re-run `bgagent linear setup` to re-authorize. +4. Check for `WARN linear_reactions: HTTP 401 from Linear` in CloudWatch — usually means the refresh token has been revoked from the Linear side, or the workspace admin uninstalled the app. Re-run `bgagent linear setup ` to re-authorize. +5. Check for `resolve_linear_api_token: invalid_grant` in CloudWatch — Linear permanently rejected the refresh token (rotation race or revocation). Re-run `bgagent linear setup ` to issue a new refresh token. ### Webhook signature verification fails repeatedly @@ -285,19 +291,19 @@ Linear's API rate limits per OAuth-installed app, per workspace: A typical task makes ~10 Linear API calls (one starting comment, one PR comment, one state transition, one final comment), nowhere near the ceiling. Heavy users should monitor the `X-RateLimit-Requests-Remaining` header in agent logs. -AgentCore Identity quotas worth knowing: +AWS quotas worth knowing: | Metric | Limit | |--------|-------| -| OAuth2 credential providers per account-region | 50 | -| Workload identities per account-region | (check Service Quotas console) | +| Secrets Manager secrets per region | 500,000 (soft) | +| Secrets Manager `GetSecretValue` ops/sec | 10,000 | -Token refresh: Linear access tokens expire in 24h (since April 2026). AgentCore Identity auto-refreshes via the stored refresh token; the agent's `get_resource_oauth2_token` call returns a fresh token transparently. +Token refresh: Linear access tokens expire in 24h (since April 2026). The webhook processor and orchestrator auto-refresh via the stored `refresh_token` and write the rotated token back to Secrets Manager. Race recovery: if Linear returns `invalid_grant` (a concurrent caller already refreshed), the resolver re-reads the secret and uses the freshly-rotated token without a second `/oauth/token` POST. ## What's out of scope in v1.x - **Comment-driven task triggers**: only labels trigger tasks. Comment commands (e.g. `@bgagent fix this`) are v2+. -- **Self-service user linking**: see Step 7 — admins must insert mapping rows manually until v2.x ships the `@bgagent link` comment flow. +- **Self-service user linking**: see Step 5 — admins must insert mapping rows manually until v2.x ships the `@bgagent link` comment flow. - **Attachments**: tickets are text-only. Linear attachments (mockups, screenshots) are planned via S3 pre-fetch. - **Per-issue status polling**: use `bgagent status` or watch the Linear issue comments. From e00e2457c853c5f3f04b2b9d9433227a25d6522c Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 19:48:19 -0400 Subject: [PATCH 017/190] =?UTF-8?q?fix(linear):=20make=20add-workspace=20f?= =?UTF-8?q?ully=20interactive=20=E2=80=94=20drop=20--client-id/--client-se?= =?UTF-8?q?cret=20flags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Secrets-on-command-line is a footgun: --client-secret leaks into ~/.zsh_history/.bash_history. The auto-detect-from-existing-workspace default also wasn't always right — when each workspace runs its own private OAuth app (the common case in multi-org production setups), auto-detect silently picks the wrong credentials and fails with a confusing "Could not find OAuth client" error after the OAuth dance. New flow: - Always prompt. Find any existing active workspace, show its client_id as the default in [brackets]. - Press Enter to accept the default (single shared OAuth app installed in multiple workspaces — the public-app case). - Type a new client_id to install with a different OAuth app per workspace (the private-app case). Then promptSecret for the new client_secret. - If the user typed the same client_id as the default, reuse the existing client_secret without prompting (no point asking the user to re-paste a secret we already have). New helper promptLine(label, defaultValue?) for non-secret input with default-on-empty semantics. promptSecret unchanged — used only for client_secret. Removes the --client-id and --client-secret flags entirely. Existing flags retained: --region, --stack-name, --no-browser, --no-actor-app. --- cli/src/commands/linear.ts | 81 ++++++++++++++++++++++++++------------ 1 file changed, 55 insertions(+), 26 deletions(-) diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index e218c413..52b4000e 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -615,8 +615,6 @@ export function makeLinearCommand(): Command { .argument('', 'Linear workspace urlKey (e.g. "acme" from linear.app/acme/...)') .option('--region ', 'AWS region (defaults to configured region)') .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') - .option('--client-id ', 'Override the OAuth Client ID (else reused from existing workspace)') - .option('--client-secret ', 'Override the OAuth Client Secret (else reused from existing workspace)') .option('--no-browser', 'Print the authorization URL instead of opening a browser (for SSH/headless)') .option('--no-actor-app', 'Drop actor=app from the OAuth flow (diagnostic)') .action(async (slug: string, opts) => { @@ -672,31 +670,39 @@ export function makeLinearCommand(): Command { const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({ region })); // ─── Linear OAuth app credentials ────────────────────────────── - // Reuse the same client_id/client_secret from an existing active - // workspace unless explicitly overridden. The setup wizard owns - // first-time credential entry; add-workspace is the "I already did - // setup, just install the app in another Linear org" path. - let clientId = opts.clientId?.trim() ?? ''; - let clientSecret = opts.clientSecret?.trim() ?? ''; + // Always prompt — never accept secrets via flags (shell history + // leak). The auto-detected client_id from an existing active + // workspace is offered as the default; user accepts with Enter + // (single OAuth app shared across workspaces) or types a new id + // (per-workspace OAuth app, e.g. when the existing app is + // private to its origin workspace). + console.log(`bgagent linear add-workspace — workspace '${slug}'`); + console.log(` region: ${region}`); + console.log(); + process.stdout.write(' → Looking for an existing workspace to reuse OAuth credentials...'); + const existing = await findReusableOauthAppCredentials(ddb, sm, workspaceRegistryTable!); + if (!existing) { + console.log(' ✗'); + throw new CliError( + 'No active Linear workspace found in the registry. ' + + 'Run `bgagent linear setup ` first to install the OAuth app, ' + + 'then re-run `bgagent linear add-workspace` for additional workspaces.', + ); + } + console.log(` ✓ (found '${existing.sourceSlug}')`); + console.log(); + console.log(' Linear OAuth credentials. Press Enter to reuse the existing app, or paste new values'); + console.log(` (e.g. when ${existing.sourceSlug}'s app is private and you created a new one in '${slug}').`); + const clientId = await promptLine(' Linear Client ID', existing.clientId); + const sameAsExisting = clientId === existing.clientId; + const clientSecret = sameAsExisting + ? existing.clientSecret + : (await promptSecret(' Linear Client Secret: ')).trim(); if (!clientId || !clientSecret) { - process.stdout.write(' → Looking for an existing workspace to reuse OAuth credentials...'); - const existing = await findReusableOauthAppCredentials(ddb, sm, workspaceRegistryTable!); - if (!existing) { - console.log(' ✗'); - throw new CliError( - 'No active Linear workspace found in the registry. ' - + 'Run `bgagent linear setup ` first to install the OAuth app, ' - + 'then re-run `bgagent linear add-workspace` for additional workspaces.', - ); - } - console.log(` ✓ (reusing credentials from '${existing.sourceSlug}')`); - clientId = clientId || existing.clientId; - clientSecret = clientSecret || existing.clientSecret; + throw new CliError('Client ID and Client Secret are both required.'); } - - console.log(`bgagent linear add-workspace — workspace '${slug}'`); - console.log(` region: ${region}`); + console.log(); // ─── PKCE + browser consent ──────────────────────────────────── const pkce = generatePkce(); @@ -779,13 +785,13 @@ export function makeLinearCommand(): Command { // Different from `setup`, which is intentionally idempotent: the // explicit add-workspace verb implies "new workspace", and silently // overwriting a registry row could mask a wrong-account login. - const existing = await ddb.send(new ScanCommand({ + const dupCheck = await ddb.send(new ScanCommand({ TableName: workspaceRegistryTable!, FilterExpression: 'linear_workspace_id = :id', ExpressionAttributeValues: { ':id': identity.organization.id }, Limit: 1, })); - if (existing.Items && existing.Items.length > 0) { + if (dupCheck.Items && dupCheck.Items.length > 0) { throw new CliError( `Workspace '${slug}' (${identity.organization.id}) is already in the registry. ` + 'Use `bgagent linear setup` to re-authorize an existing workspace, or remove the registry row manually before retrying.', @@ -1056,6 +1062,29 @@ function promptSecret(label: string): Promise { }); } +/** + * Read a single line from stdin, with an optional default that's accepted on + * empty input (Enter without typing). Visible echo — use only for non-secret + * fields. For secrets, use `promptSecret`. + * + * Used by `bgagent linear add-workspace` to show the auto-detected client_id + * as a default the user can override by typing a new value. + */ +function promptLine(label: string, defaultValue?: string): Promise { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + const display = defaultValue + ? `${label} [${defaultValue}]: ` + : `${label}: `; + return new Promise((resolve, reject) => { + rl.question(display, (line) => { + rl.close(); + const trimmed = line.trim(); + resolve(trimmed || defaultValue || ''); + }); + rl.once('close', () => reject(new Error('No input provided.'))); + }); +} + // ─── Auto-link ─────────────────────────────────────────────────────────────── interface LinearViewer { From eb0e16988cf551afe190623c220734404690f09f Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 20:11:25 -0400 Subject: [PATCH 018/190] fix(linear): drop source workspace name from add-workspace prompt prose The interactive prompt previously printed 'found ' and named the source workspace in the explanation hint. The slug still appears as the default value in [brackets] (structurally necessary), but no longer leaks into instructional prose where a generic phrasing works just as well. --- cli/src/commands/linear.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 52b4000e..c5fa5683 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -690,10 +690,10 @@ export function makeLinearCommand(): Command { + 'then re-run `bgagent linear add-workspace` for additional workspaces.', ); } - console.log(` ✓ (found '${existing.sourceSlug}')`); + console.log(' ✓'); console.log(); console.log(' Linear OAuth credentials. Press Enter to reuse the existing app, or paste new values'); - console.log(` (e.g. when ${existing.sourceSlug}'s app is private and you created a new one in '${slug}').`); + console.log(' (the existing app may be private to its origin workspace and not authorize cross-install).'); const clientId = await promptLine(' Linear Client ID', existing.clientId); const sameAsExisting = clientId === existing.clientId; const clientSecret = sameAsExisting From 33c915963c7e5c7b2734f1669b5f64d2332daa8d Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 21:40:43 -0400 Subject: [PATCH 019/190] fix(linear): rewrite promptLine in raw stdin mode so it composes with promptSecret Previous implementation used readline.createInterface + rl.close(), which leaves stdin in EOF state. Chaining a promptLine call followed by a promptSecret call (which add-workspace does for client_id then client_secret) makes the second readline interface fire 'close' immediately and reject with 'No input provided.' Switch to the same raw-mode stdin pattern as promptSecret: register a 'data' handler, accumulate characters, echo each one (visibly, since this is for non-secret input), unwind cleanly on Enter. Both prompts now manage stdin consistently and chain without state leakage. --- cli/src/commands/linear.ts | 67 +++++++++++++++++++++++++++++++------- 1 file changed, 55 insertions(+), 12 deletions(-) diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index c5fa5683..87f95edf 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -1067,21 +1067,64 @@ function promptSecret(label: string): Promise { * empty input (Enter without typing). Visible echo — use only for non-secret * fields. For secrets, use `promptSecret`. * - * Used by `bgagent linear add-workspace` to show the auto-detected client_id - * as a default the user can override by typing a new value. + * Implemented with the same raw-mode stdin pattern as `promptSecret` (just + * echoing the typed character instead of '*') so that chaining a promptLine + * call followed by a promptSecret call works — `readline.createInterface` + * + `rl.close()` would leave stdin in an EOF state and the next prompt + * would reject immediately on its own readline `close` event. */ function promptLine(label: string, defaultValue?: string): Promise { - const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); - const display = defaultValue - ? `${label} [${defaultValue}]: ` - : `${label}: `; return new Promise((resolve, reject) => { - rl.question(display, (line) => { - rl.close(); - const trimmed = line.trim(); - resolve(trimmed || defaultValue || ''); - }); - rl.once('close', () => reject(new Error('No input provided.'))); + const display = defaultValue ? `${label} [${defaultValue}]: ` : `${label}: `; + process.stderr.write(display); + + if (!process.stdin.isTTY) { + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }); + rl.once('line', (line) => { + rl.close(); + resolve(line.trim() || defaultValue || ''); + }); + rl.once('close', () => reject(new Error('No input provided.'))); + return; + } + + process.stdin.setRawMode(true); + process.stdin.resume(); + + let value = ''; + + const cleanup = () => { + process.stdin.removeListener('data', onData); + process.stdin.setRawMode(false); + process.stdin.pause(); + }; + + 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() || defaultValue || ''); + 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(char); + } + } + }; + + process.stdin.on('data', onData); }); } From fb65e5e08a50049807a3de5259dfefa36f5f49f8 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Thu, 21 May 2026 01:01:40 -0700 Subject: [PATCH 020/190] fix(cli): bgagent linear list-projects on the OAuth secret model 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 ` to scope to one workspace; without it, queries every installed workspace and labels each project with its source. Also: switch to the `Bearer ` 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) --- cli/src/commands/linear.ts | 127 +++++++++++++++++++++++++------------ cli/src/linear-oauth.ts | 10 ++- 2 files changed, 94 insertions(+), 43 deletions(-) diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 87f95edf..cd88a87c 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -24,6 +24,7 @@ import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { CreateSecretCommand, GetSecretValueCommand, + ListSecretsCommand, PutSecretValueCommand, ResourceExistsException, SecretsManagerClient, @@ -39,6 +40,7 @@ import { computeExpiresAt, exchangeAuthorizationCode, generatePkce, + LINEAR_OAUTH_SECRET_PREFIX, linearOauthSecretName, StoredLinearOauthToken, } from '../linear-oauth'; @@ -929,67 +931,108 @@ export function makeLinearCommand(): Command { linear.addCommand( new Command('list-projects') - .description('List Linear projects visible to the stored API token (with full UUIDs)') + .description('List Linear projects visible to the OAuth-installed workspace (with full UUIDs)') .option('--region ', 'AWS region (defaults to configured region)') - .option('--stack-name ', 'CloudFormation stack name', 'backgroundagent-dev') + .option('--slug ', 'Linear workspace slug (urlKey). If omitted, queries every active workspace in the registry.') .option('--output ', 'Output format (text or json)', 'text') .action(async (opts) => { const config = loadConfig(); const region = opts.region || config.region; + const sm = new SecretsManagerClient({ region }); - const apiTokenSecretArn = await getStackOutput(region, opts.stackName, 'LinearApiTokenSecretArn'); - if (!apiTokenSecretArn) { - console.error('Could not find LinearApiTokenSecretArn in stack outputs. Deploy the stack first.'); - process.exit(1); + // Resolve the set of workspace slugs to query. Either an + // explicit `--slug` (one workspace) or every Linear workspace + // we have an OAuth secret for (list every `bgagent-linear-oauth-*`). + let slugs: string[]; + if (opts.slug) { + slugs = [opts.slug]; + } else { + const listed = await sm.send(new ListSecretsCommand({ + Filters: [{ Key: 'name', Values: [LINEAR_OAUTH_SECRET_PREFIX] }], + })); + slugs = (listed.SecretList ?? []) + .map((s) => s.Name ?? '') + .filter((n) => n.startsWith(LINEAR_OAUTH_SECRET_PREFIX)) + .map((n) => n.slice(LINEAR_OAUTH_SECRET_PREFIX.length)); + if (slugs.length === 0) { + console.error('No Linear OAuth installs found. Run `bgagent linear setup ` first.'); + process.exit(1); + } } - const sm = new SecretsManagerClient({ region }); - const secret = await sm.send(new GetSecretValueCommand({ SecretId: apiTokenSecretArn })); - const apiToken = secret.SecretString; - if (!apiToken || apiToken === ' ') { - console.error('Linear API token is not populated. Run `bgagent linear setup` first.'); - process.exit(1); - } + type ProjectRow = { + slug: string; + id: string; + name: string; + team?: string; + }; + const rows: ProjectRow[] = []; + + for (const slug of slugs) { + const secretName = linearOauthSecretName(slug); + let accessToken: string; + try { + const resp = await sm.send(new GetSecretValueCommand({ SecretId: secretName })); + const stored = JSON.parse(resp.SecretString ?? '{}') as { access_token?: string }; + if (!stored.access_token) { + console.error(`Secret ${secretName} is missing access_token; skipping.`); + continue; + } + accessToken = stored.access_token; + } catch (err) { + console.error(`Failed to read ${secretName}: ${err instanceof Error ? err.message : String(err)}`); + continue; + } - let projects: Array<{ id: string; name: string; teams?: { nodes?: Array<{ id: string; name: string }> } }>; - try { - const res = await fetch('https://api.linear.app/graphql', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Authorization': apiToken, - }, - body: JSON.stringify({ - query: '{ projects { nodes { id name teams { nodes { id name } } } } }', - }), - }); - if (!res.ok) { - throw new Error(`Linear API returned ${res.status}`); + try { + const res = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${accessToken}`, + }, + body: JSON.stringify({ + query: '{ projects(first: 100) { nodes { id name teams(first: 1) { nodes { name } } } } }', + }), + }); + if (!res.ok) { + console.error(`Linear API returned ${res.status} for workspace '${slug}'`); + continue; + } + const body = await res.json() as { + data?: { projects?: { nodes?: Array<{ id: string; name: string; teams?: { nodes?: Array<{ name: string }> } }> } }; + }; + for (const p of body.data?.projects?.nodes ?? []) { + rows.push({ + slug, + id: p.id, + name: p.name, + team: p.teams?.nodes?.[0]?.name, + }); + } + } catch (err) { + console.error(`Failed to fetch projects for '${slug}': ${err instanceof Error ? err.message : String(err)}`); + continue; } - const body = await res.json() as { data?: { projects?: { nodes?: typeof projects } } }; - projects = body.data?.projects?.nodes ?? []; - } catch (err) { - console.error(`Failed to fetch Linear projects: ${err instanceof Error ? err.message : String(err)}`); - process.exit(1); } if (opts.output === 'json') { - console.log(formatJson(projects)); + console.log(formatJson(rows)); return; } - if (projects.length === 0) { - console.log('No Linear projects visible to the stored API token.'); + if (rows.length === 0) { + console.log('No Linear projects visible to any installed workspace.'); return; } - console.log(`Found ${projects.length} Linear project(s):\n`); - for (const p of projects) { - const team = p.teams?.nodes?.[0]; - console.log(` ${p.name}`); - console.log(` id: ${p.id}`); - if (team) { - console.log(` team: ${team.name} (${team.id})`); + console.log(`Found ${rows.length} Linear project(s):\n`); + for (const r of rows) { + console.log(` ${r.name}`); + console.log(` id: ${r.id}`); + console.log(` workspace: ${r.slug}`); + if (r.team) { + console.log(` team: ${r.team}`); } console.log(''); } diff --git a/cli/src/linear-oauth.ts b/cli/src/linear-oauth.ts index c2ce2902..d23e390d 100644 --- a/cli/src/linear-oauth.ts +++ b/cli/src/linear-oauth.ts @@ -88,13 +88,21 @@ export interface StoredLinearOauthToken { readonly installed_by_platform_user_id: string; } +/** + * Common prefix for all per-workspace Linear OAuth secrets. The full + * secret name is `${LINEAR_OAUTH_SECRET_PREFIX}`. Use this when + * scanning Secrets Manager for every workspace install (e.g. the CLI's + * `list-projects` command queries every workspace it can find). + */ +export const LINEAR_OAUTH_SECRET_PREFIX = 'bgagent-linear-oauth-'; + /** * Build the secret name for a given Linear workspace slug. Matches the * naming convention encoded in the runtime's IAM policy resource pattern, * so changes here MUST be matched by the IAM resource pattern in CDK. */ export function linearOauthSecretName(workspaceSlug: string): string { - return `bgagent-linear-oauth-${workspaceSlug}`; + return `${LINEAR_OAUTH_SECRET_PREFIX}${workspaceSlug}`; } /** From 7ee9775b68455989400f90084032a7280a8e8d46 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 21:46:16 -0400 Subject: [PATCH 021/190] fix(linear): clearer empty-result message in list-projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 'No Linear projects visible to any installed workspace' read like an OAuth-scope or IAM problem when the API call succeeded — the workspace just has zero projects. Differentiate the single-workspace and multi-workspace cases and tell the user what to do (create a project). --- cli/src/commands/linear.ts | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index cd88a87c..c8c2acfa 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -1022,7 +1022,19 @@ export function makeLinearCommand(): Command { } if (rows.length === 0) { - console.log('No Linear projects visible to any installed workspace.'); + // The Linear API call succeeded for every workspace (otherwise the + // continue-on-error branches above would have logged), so the + // workspaces are reachable — they just don't have any projects. + // Surface that explicitly so the user doesn't read "No projects + // visible" as an OAuth-scope or IAM problem and start chasing + // ghosts. + if (slugs.length === 1) { + console.log(`Workspace '${slugs[0]}' has no projects yet.`); + console.log(`Create one in Linear (https://linear.app/${slugs[0]}/), then re-run.`); + } else { + console.log(`No projects found in any of: ${slugs.join(', ')}.`); + console.log('Create a project in at least one workspace, then re-run.'); + } return; } From ac6bb34ec8cc23a9288c588addbbc6724ccf124d Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 22:17:09 -0400 Subject: [PATCH 022/190] feat(linear): per-workspace webhook signing secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Linear generates a fresh signing secret per webhook subscription, and webhook subscriptions are workspace-scoped. The previous single stack-wide LinearWebhookSecret could only verify events from the workspace that owned its value — events from any other workspace silently failed signature verification. add-workspace shipped earlier today made this concrete: demo-abca couldn't trigger tasks because its events failed verification against maguireb's signing secret. Schema: add optional `webhook_signing_secret` to StoredOauthToken (TS Lambda) and StoredLinearOauthToken (TS CLI). Optional preserves back-compat with installs predating this change. Cross-language parity test extended to allow optional fields and check that the required-fields validator const matches the interface's required set. Webhook receiver: parse body once, peek at organizationId (untrusted — used only to select WHICH secret to verify against), call new verifyLinearRequestForWorkspace which returns 'verified', 'mismatch', or 'no-per-workspace-secret'. On 'verified': dispatch. On 'mismatch': reject 401 (NO fallback — would let an attacker bypass the per-workspace secret by also matching stack-wide). On 'no-per-workspace-secret': fall through to the existing stack-wide verifyLinearRequest path for back-compat. CDK: webhook receiver Lambda gets registry table read + SM GetSecretValue on the bgagent-linear-oauth-* prefix + LINEAR_WORKSPACE_REGISTRY_TABLE_NAME env var. Stack-wide secret left in place (single-workspace fallback path). CLI setup: now writes the webhook signing secret to BOTH the per- workspace OAuth bundle AND (on first install only) the stack-wide secret. Re-running setup on an existing single-workspace install auto-mirrors the stack-wide value into the per-workspace bundle — zero-config migration. --rotate-webhook-secret re-prompts. CLI add-workspace: always prompts for the workspace's signing secret (no shared secret to reuse). Refuses to overwrite the stack-wide secret since multi-workspace installs can't meaningfully share one stack-wide value. Tests: - Multi-workspace test file with 6 cases including the critical cross-workspace impersonation rejection (workspace A signed with workspace B's secret → 401, lambda not invoked). - Single-workspace back-compat: registry miss → fallback works. - Migration mid-state: bundle without webhook_signing_secret → fallback works. - Revoked workspace + no fallback match → 401. Trust model preserved end-to-end: organizationId in body is attacker-controlled but only selects the secret; signature still gates everything. Documented in LINEAR_SETUP_GUIDE.md "How webhook signature verification works". --- cdk/src/constructs/linear-integration.ts | 21 ++ cdk/src/handlers/linear-webhook.ts | 53 ++- .../handlers/shared/linear-oauth-resolver.ts | 17 +- cdk/src/handlers/shared/linear-verify.ts | 59 ++++ .../stored-oauth-token-parity.test.ts | 48 ++- .../linear-webhook-multi-workspace.test.ts | 317 ++++++++++++++++++ cli/src/commands/linear.ts | 113 ++++++- cli/src/linear-oauth.ts | 13 + docs/guides/LINEAR_SETUP_GUIDE.md | 30 +- .../content/docs/using/Linear-setup-guide.md | 30 +- 10 files changed, 654 insertions(+), 47 deletions(-) create mode 100644 cdk/test/handlers/linear-webhook-multi-workspace.test.ts diff --git a/cdk/src/constructs/linear-integration.ts b/cdk/src/constructs/linear-integration.ts index 37adde7e..5a079e82 100644 --- a/cdk/src/constructs/linear-integration.ts +++ b/cdk/src/constructs/linear-integration.ts @@ -245,11 +245,32 @@ export class LinearIntegration extends Construct { LINEAR_WEBHOOK_SECRET_ARN: this.webhookSecret.secretArn, LINEAR_WEBHOOK_DEDUP_TABLE_NAME: this.webhookDedupTable.tableName, LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME: webhookProcessorFn.functionName, + // Per-workspace signing-secret lookup — selects the right + // workspace's `webhook_signing_secret` from the OAuth secret + // bundle so multi-workspace installs verify correctly. Receiver + // falls back to LINEAR_WEBHOOK_SECRET_ARN when this lookup + // misses (back-compat for single-workspace installs). + LINEAR_WORKSPACE_REGISTRY_TABLE_NAME: this.workspaceRegistryTable.tableName, }, bundling: commonBundling, }); this.webhookSecret.grantRead(webhookFn); this.webhookDedupTable.grantReadWriteData(webhookFn); + this.workspaceRegistryTable.grantReadData(webhookFn); + // Read-only on the per-workspace OAuth secret prefix — we extract + // `webhook_signing_secret` for verification but never mutate; the + // CLI owns the lifecycle of these secrets. + webhookFn.addToRolePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: [ + Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + arnFormat: ArnFormat.COLON_RESOURCE_NAME, + resourceName: 'bgagent-linear-oauth-*', + }), + ], + })); webhookProcessorFn.grantInvoke(webhookFn); // --- Account linking (Cognito-authenticated) --- diff --git a/cdk/src/handlers/linear-webhook.ts b/cdk/src/handlers/linear-webhook.ts index 72def0e4..6d22fad0 100644 --- a/cdk/src/handlers/linear-webhook.ts +++ b/cdk/src/handlers/linear-webhook.ts @@ -21,7 +21,11 @@ import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client 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 { isWebhookTimestampFresh, verifyLinearRequest } from './shared/linear-verify'; +import { + isWebhookTimestampFresh, + verifyLinearRequest, + verifyLinearRequestForWorkspace, +} from './shared/linear-verify'; import { logger } from './shared/logger'; const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); @@ -30,6 +34,10 @@ const lambdaClient = new LambdaClient({}); const WEBHOOK_SECRET_ARN = process.env.LINEAR_WEBHOOK_SECRET_ARN!; const DEDUP_TABLE_NAME = process.env.LINEAR_WEBHOOK_DEDUP_TABLE_NAME!; const PROCESSOR_FUNCTION_NAME = process.env.LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME!; +/** Optional. When unset, the per-workspace signing-secret path is skipped + * and only the stack-wide secret is consulted (back-compat for installs + * predating per-workspace secrets). */ +const WORKSPACE_REGISTRY_TABLE = process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME; /** * Dedup window (seconds). Must exceed Linear's full retry horizon: first @@ -79,11 +87,11 @@ export async function handler(event: APIGatewayProxyEvent): Promise 'installed_by_platform_user_id', ]; -async function getOauthSecret( +export async function getOauthSecret( sm: SecretsManagerClient, secretArn: string, ): Promise { diff --git a/cdk/src/handlers/shared/linear-verify.ts b/cdk/src/handlers/shared/linear-verify.ts index 5199fcfb..c8b9c52e 100644 --- a/cdk/src/handlers/shared/linear-verify.ts +++ b/cdk/src/handlers/shared/linear-verify.ts @@ -18,10 +18,14 @@ */ import * as crypto from 'crypto'; +import { DynamoDBClient } from '@aws-sdk/client-dynamodb'; import { GetSecretValueCommand, SecretsManagerClient } from '@aws-sdk/client-secrets-manager'; +import { DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; +import { getOauthSecret, getRegistryRow } from './linear-oauth-resolver'; import { logger } from './logger'; const sm = new SecretsManagerClient({}); +const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({})); /** Prefix for Linear-related secrets in Secrets Manager. */ export const LINEAR_SECRET_PREFIX = 'bgagent/linear/'; @@ -150,3 +154,58 @@ export async function verifyLinearRequest( if (fresh === cached) return false; return verifyLinearSignature(fresh, signature, body); } + +/** + * Verify a Linear webhook request against the **per-workspace** signing + * secret stored alongside the workspace's OAuth token bundle. + * + * Linear generates a fresh signing secret per webhook subscription, and + * webhook subscriptions are workspace-scoped — so a stack-wide signing + * secret cannot verify events from multiple workspaces. This path: + * + * 1. Looks up the registry row keyed on `linear_workspace_id` (the + * orgId from the webhook payload — claimed, not yet trusted). + * 2. Reads the per-workspace OAuth secret to extract + * `webhook_signing_secret`. + * 3. Verifies the HMAC signature against that secret. + * + * The orgId is untrusted input from the webhook body; an attacker can + * claim any orgId. But it only **selects which secret to verify + * against** — they still need the correct signing secret to forge a + * valid signature, which they don't have. The trust model is + * preserved. + * + * Returns: + * - `'verified'` — signature matches the per-workspace secret. Caller + * trusts the body. + * - `'mismatch'` — registry row + secret were found, but the signature + * doesn't match. Caller MUST reject (do not fall back to stack-wide; + * that would let an attacker bypass the per-workspace secret by + * tricking us into re-checking against the stack-wide one). + * - `'no-per-workspace-secret'` — registry miss, secret missing, or + * `webhook_signing_secret` field absent in the secret JSON. Caller + * should fall back to the stack-wide secret for back-compat. + * + * @param registryTableName - DynamoDB table for `LinearWorkspaceRegistryTable`. + * @param linearWorkspaceId - the claimed `organizationId` from the body. + * @param signature - the `Linear-Signature` header value. + * @param body - the raw request body string. + */ +export async function verifyLinearRequestForWorkspace( + registryTableName: string, + linearWorkspaceId: string, + signature: string, + body: string, +): Promise<'verified' | 'mismatch' | 'no-per-workspace-secret'> { + const row = await getRegistryRow(ddb, registryTableName, linearWorkspaceId); + if (!row || row.status !== 'active') { + return 'no-per-workspace-secret'; + } + const stored = await getOauthSecret(sm, row.oauth_secret_arn); + if (!stored || !stored.webhook_signing_secret) { + return 'no-per-workspace-secret'; + } + return verifyLinearSignature(stored.webhook_signing_secret, signature, body) + ? 'verified' + : 'mismatch'; +} diff --git a/cdk/test/contracts/stored-oauth-token-parity.test.ts b/cdk/test/contracts/stored-oauth-token-parity.test.ts index faa23a36..a3b86e72 100644 --- a/cdk/test/contracts/stored-oauth-token-parity.test.ts +++ b/cdk/test/contracts/stored-oauth-token-parity.test.ts @@ -40,45 +40,59 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..', '..'); const LAMBDA_RESOLVER = path.join(REPO_ROOT, 'cdk', 'src', 'handlers', 'shared', 'linear-oauth-resolver.ts'); const CLI_OAUTH = path.join(REPO_ROOT, 'cli', 'src', 'linear-oauth.ts'); -function extractInterfaceFields(source: string, interfaceName: string): string[] { +interface InterfaceField { + readonly name: string; + readonly optional: boolean; +} + +function extractInterfaceFields(source: string, interfaceName: string): InterfaceField[] { const reBlock = new RegExp(`export\\s+interface\\s+${interfaceName}\\s*\\{([\\s\\S]*?)\\n\\}`); const match = reBlock.exec(source); if (!match) { throw new Error(`Could not find interface ${interfaceName}`); } const body = match[1]; - const fields: string[] = []; + const fields: InterfaceField[] = []; // Match `readonly :` or `:` field declarations. Skip // lines that are inside JSDoc comment blocks (start with `*`) or - // single-line comments (`//`). + // single-line comments (`//`). Capture the `?` to track optional. for (const rawLine of body.split('\n')) { const line = rawLine.trim(); if (!line || line.startsWith('//') || line.startsWith('/*') || line.startsWith('*')) continue; - const fieldMatch = /^(?:readonly\s+)?([a-zA-Z_][a-zA-Z0-9_]*)\??\s*:/.exec(line); + const fieldMatch = /^(?:readonly\s+)?([a-zA-Z_][a-zA-Z0-9_]*)(\??)\s*:/.exec(line); if (fieldMatch) { - fields.push(fieldMatch[1]); + fields.push({ name: fieldMatch[1], optional: fieldMatch[2] === '?' }); } } return fields; } +function fieldNames(fields: InterfaceField[]): string[] { + return fields.map((f) => f.name).sort(); +} + +function requiredFieldNames(fields: InterfaceField[]): string[] { + return fields.filter((f) => !f.optional).map((f) => f.name).sort(); +} + describe('StoredOauthToken / StoredLinearOauthToken cross-language parity', () => { - test('Lambda and CLI define the same set of fields', () => { + test('Lambda and CLI define the same set of fields with the same optionality', () => { const lambdaSource = fs.readFileSync(LAMBDA_RESOLVER, 'utf8'); const cliSource = fs.readFileSync(CLI_OAUTH, 'utf8'); - const lambdaFields = extractInterfaceFields(lambdaSource, 'StoredOauthToken').sort(); - const cliFields = extractInterfaceFields(cliSource, 'StoredLinearOauthToken').sort(); + const lambdaFields = extractInterfaceFields(lambdaSource, 'StoredOauthToken'); + const cliFields = extractInterfaceFields(cliSource, 'StoredLinearOauthToken'); - expect(lambdaFields).toEqual(cliFields); - // Sanity: at least 11 fields per the documented schema. Catches - // a regex parse failure that returns empty arrays. - expect(lambdaFields.length).toBeGreaterThanOrEqual(11); + expect(fieldNames(lambdaFields)).toEqual(fieldNames(cliFields)); + expect(requiredFieldNames(lambdaFields)).toEqual(requiredFieldNames(cliFields)); + // Sanity: at least 11 required fields per the documented schema. + // Catches a regex parse failure that returns empty arrays. + expect(requiredFieldNames(lambdaFields).length).toBeGreaterThanOrEqual(11); }); - test('Lambda STORED_OAUTH_TOKEN_REQUIRED_FIELDS const matches the interface', () => { + test('Lambda STORED_OAUTH_TOKEN_REQUIRED_FIELDS const matches the interface\'s required fields', () => { const lambdaSource = fs.readFileSync(LAMBDA_RESOLVER, 'utf8'); - const interfaceFields = extractInterfaceFields(lambdaSource, 'StoredOauthToken').sort(); + const interfaceRequired = requiredFieldNames(extractInterfaceFields(lambdaSource, 'StoredOauthToken')); const constMatch = /STORED_OAUTH_TOKEN_REQUIRED_FIELDS:\s*ReadonlyArray\s*=\s*\[([\s\S]*?)\];/.exec(lambdaSource); expect(constMatch).not.toBeNull(); @@ -86,6 +100,10 @@ describe('StoredOauthToken / StoredLinearOauthToken cross-language parity', () = .map((s) => s.replace(/'/g, '')) .sort(); - expect(constFields).toEqual(interfaceFields); + // The const should list exactly the required (non-optional) fields. + // Optional fields like `webhook_signing_secret` (back-compat for + // installs predating per-workspace signing) MUST NOT be listed — + // doing so would reject every existing install on Lambda startup. + expect(constFields).toEqual(interfaceRequired); }); }); diff --git a/cdk/test/handlers/linear-webhook-multi-workspace.test.ts b/cdk/test/handlers/linear-webhook-multi-workspace.test.ts new file mode 100644 index 00000000..2be2a674 --- /dev/null +++ b/cdk/test/handlers/linear-webhook-multi-workspace.test.ts @@ -0,0 +1,317 @@ +/** + * 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. + */ + +/** + * Per-workspace webhook signing-secret tests for the Linear webhook handler. + * + * Lives in a separate file from `linear-webhook.test.ts` because the + * handler reads `LINEAR_WORKSPACE_REGISTRY_TABLE_NAME` at module-load + * time. Setting it here before the import gives us the multi-workspace + * code path; the sibling test file leaves it unset to exercise the + * single-workspace back-compat path. + */ + +import * as crypto from 'crypto'; +import type { APIGatewayProxyEvent } from 'aws-lambda'; + +const ddbSend = jest.fn(); +jest.mock('@aws-sdk/client-dynamodb', () => { + class ConditionalCheckFailedExceptionMock extends Error { + constructor(opts: { message: string; $metadata?: unknown }) { + super(opts.message); + this.name = 'ConditionalCheckFailedException'; + } + } + return { + DynamoDBClient: jest.fn(() => ({})), + ConditionalCheckFailedException: ConditionalCheckFailedExceptionMock, + }; +}); +jest.mock('@aws-sdk/lib-dynamodb', () => ({ + DynamoDBDocumentClient: { from: jest.fn(() => ({ send: ddbSend })) }, + PutCommand: jest.fn((input: unknown) => ({ _type: 'Put', input })), + DeleteCommand: jest.fn((input: unknown) => ({ _type: 'Delete', input })), + GetCommand: jest.fn((input: unknown) => ({ _type: 'Get', input })), +})); + +const lambdaSend = jest.fn(); +jest.mock('@aws-sdk/client-lambda', () => ({ + LambdaClient: jest.fn(() => ({ send: lambdaSend })), + InvokeCommand: jest.fn((input: unknown) => ({ _type: 'Invoke', input })), +})); + +const smSend = jest.fn(); +jest.mock('@aws-sdk/client-secrets-manager', () => ({ + SecretsManagerClient: jest.fn(() => ({ send: smSend })), + GetSecretValueCommand: jest.fn((input: unknown) => ({ _type: 'GetSecretValue', input })), +})); + +process.env.LINEAR_WEBHOOK_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent/linear/webhook-stack'; +process.env.LINEAR_WEBHOOK_DEDUP_TABLE_NAME = 'LinearDedup'; +process.env.LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME = 'linear-processor'; +process.env.LINEAR_WORKSPACE_REGISTRY_TABLE_NAME = 'LinearWorkspaceRegistry'; + +import { handler } from '../../src/handlers/linear-webhook'; +import { invalidateLinearSecretCache } from '../../src/handlers/shared/linear-verify'; +import { invalidateLinearOauthCache } from '../../src/handlers/shared/linear-oauth-resolver'; + +const STACK_WIDE_SECRET = 'lin_wh_stackwide_AAAAAAAAAAAAAAAAAA'; +const WORKSPACE_A_SECRET = 'lin_wh_workspaceA_BBBBBBBBBBBBBBBBBB'; +const WORKSPACE_B_SECRET = 'lin_wh_workspaceB_CCCCCCCCCCCCCCCCCC'; +const WORKSPACE_A_ID = 'org-aaa'; +const WORKSPACE_B_ID = 'org-bbb'; +const WORKSPACE_A_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent-linear-oauth-acme-A'; +const WORKSPACE_B_SECRET_ARN = 'arn:aws:secretsmanager:us-east-1:123:secret:bgagent-linear-oauth-acme-B'; + +function sign(secret: string, body: string): string { + return crypto.createHmac('sha256', secret).update(body).digest('hex'); +} + +function makeEvent(body: string, signature: string): APIGatewayProxyEvent { + return { + body, + headers: { 'Linear-Signature': signature }, + multiValueHeaders: {}, + httpMethod: 'POST', + isBase64Encoded: false, + path: '/v1/linear/webhook', + pathParameters: null, + queryStringParameters: null, + multiValueQueryStringParameters: null, + stageVariables: null, + requestContext: {} as APIGatewayProxyEvent['requestContext'], + resource: '', + }; +} + +function payloadFor(orgId: string): string { + return JSON.stringify({ + action: 'create', + type: 'Issue', + webhookTimestamp: Date.now(), + webhookId: 'wh-1', + organizationId: orgId, + data: { id: 'issue-1', labels: [{ id: 'lbl-1', name: 'bgagent' }] }, + }); +} + +interface StoredOauthFixture { + readonly access_token: string; + readonly refresh_token: string; + readonly expires_at: string; + readonly scope: string; + readonly client_id: string; + readonly client_secret: string; + readonly workspace_id: string; + readonly workspace_slug: string; + readonly installed_at: string; + readonly updated_at: string; + readonly installed_by_platform_user_id: string; + readonly webhook_signing_secret?: string; +} + +function makeStoredOauth(overrides: Partial = {}): StoredOauthFixture { + return { + access_token: 'lin_oauth_xxx', + refresh_token: 'lin_refresh_xxx', + expires_at: new Date(Date.now() + 12 * 3600 * 1000).toISOString(), + scope: 'read write app:assignable app:mentionable', + client_id: 'cid', + client_secret: 'csec', + workspace_id: 'org-default', + workspace_slug: 'acme', + installed_at: '2026-05-19T08:00:00Z', + updated_at: '2026-05-19T08:00:00Z', + installed_by_platform_user_id: 'cog-sub', + ...overrides, + }; +} + +/** + * Wire the SM mock to respond by SecretId. Tests configure the + * per-workspace OAuth secrets and the stack-wide signing secret on a + * single mock so the receiver can fall through cleanly. + */ +function configureSecretsManager(secrets: Record) { + smSend.mockImplementation((cmd: { input: { SecretId: string } }) => { + const id = cmd.input.SecretId; + const value = secrets[id]; + if (value === undefined) { + const err = new Error(`SecretId not mocked: ${id}`); + (err as Error & { name: string }).name = 'ResourceNotFoundException'; + return Promise.reject(err); + } + return Promise.resolve({ + SecretString: typeof value === 'string' ? value : JSON.stringify(value), + }); + }); +} + +/** + * Wire DDB to return registry rows by `linear_workspace_id`. + * Workspaces not listed return `Item: undefined` (registry miss). + */ +function configureRegistry(rows: Record) { + ddbSend.mockImplementation((cmd: { _type?: string; input: Record }) => { + if (cmd._type === 'Get') { + const key = cmd.input.Key as { linear_workspace_id?: string } | undefined; + const workspaceId = key?.linear_workspace_id; + const item = workspaceId ? rows[workspaceId] : undefined; + return Promise.resolve(item ? { Item: { linear_workspace_id: workspaceId, ...item } } : { Item: undefined }); + } + if (cmd._type === 'Put') { + // Dedup PutItem — succeed. + return Promise.resolve({}); + } + if (cmd._type === 'Delete') { + return Promise.resolve({}); + } + return Promise.resolve({}); + }); +} + +describe('linear-webhook handler — multi-workspace signature verification', () => { + beforeEach(() => { + ddbSend.mockReset(); + smSend.mockReset(); + lambdaSend.mockReset(); + invalidateLinearSecretCache(process.env.LINEAR_WEBHOOK_SECRET_ARN!); + invalidateLinearOauthCache(WORKSPACE_A_ID, WORKSPACE_A_SECRET_ARN); + invalidateLinearOauthCache(WORKSPACE_B_ID, WORKSPACE_B_SECRET_ARN); + lambdaSend.mockResolvedValue({}); + }); + + test('verifies workspace A using its per-workspace signing secret', async () => { + configureRegistry({ + [WORKSPACE_A_ID]: { oauth_secret_arn: WORKSPACE_A_SECRET_ARN, status: 'active', workspace_slug: 'acme-A' }, + }); + configureSecretsManager({ + [WORKSPACE_A_SECRET_ARN]: makeStoredOauth({ + workspace_id: WORKSPACE_A_ID, + workspace_slug: 'acme-A', + webhook_signing_secret: WORKSPACE_A_SECRET, + }), + }); + const body = payloadFor(WORKSPACE_A_ID); + const result = await handler(makeEvent(body, sign(WORKSPACE_A_SECRET, body))); + expect(result.statusCode).toBe(200); + // Processor was invoked → signature passed. + expect(lambdaSend).toHaveBeenCalledTimes(1); + }); + + test('verifies workspace B using its DIFFERENT per-workspace signing secret', async () => { + configureRegistry({ + [WORKSPACE_B_ID]: { oauth_secret_arn: WORKSPACE_B_SECRET_ARN, status: 'active', workspace_slug: 'acme-B' }, + }); + configureSecretsManager({ + [WORKSPACE_B_SECRET_ARN]: makeStoredOauth({ + workspace_id: WORKSPACE_B_ID, + workspace_slug: 'acme-B', + webhook_signing_secret: WORKSPACE_B_SECRET, + }), + }); + const body = payloadFor(WORKSPACE_B_ID); + const result = await handler(makeEvent(body, sign(WORKSPACE_B_SECRET, body))); + expect(result.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + }); + + test('rejects workspace A signed with workspace B\'s secret (per-workspace mismatch is fatal)', async () => { + // The CRITICAL test: an attacker who learns workspace B's signing + // secret (or replays a workspace B event) cannot dispatch as + // workspace A by claiming A's orgId. The receiver locks the + // per-workspace path once it finds A's secret and refuses to fall + // back to the stack-wide secret. + configureRegistry({ + [WORKSPACE_A_ID]: { oauth_secret_arn: WORKSPACE_A_SECRET_ARN, status: 'active', workspace_slug: 'acme-A' }, + }); + configureSecretsManager({ + [WORKSPACE_A_SECRET_ARN]: makeStoredOauth({ + workspace_id: WORKSPACE_A_ID, + workspace_slug: 'acme-A', + webhook_signing_secret: WORKSPACE_A_SECRET, + }), + [process.env.LINEAR_WEBHOOK_SECRET_ARN!]: STACK_WIDE_SECRET, + }); + const body = payloadFor(WORKSPACE_A_ID); + // Sign with WORKSPACE_B_SECRET — wrong secret for A. + const result = await handler(makeEvent(body, sign(WORKSPACE_B_SECRET, body))); + expect(result.statusCode).toBe(401); + // Lambda must NOT be invoked. + expect(lambdaSend).not.toHaveBeenCalled(); + }); + + test('falls back to stack-wide secret when registry has no row for the orgId', async () => { + // Single-workspace back-compat: an old install written before the + // per-workspace flow has no registry row keyed on its orgId. The + // receiver falls through to the stack-wide secret and verifies + // with that. Existing single-workspace deployments keep working. + configureRegistry({}); // empty — registry miss + configureSecretsManager({ + [process.env.LINEAR_WEBHOOK_SECRET_ARN!]: STACK_WIDE_SECRET, + }); + const body = payloadFor('org-not-onboarded'); + const result = await handler(makeEvent(body, sign(STACK_WIDE_SECRET, body))); + expect(result.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + }); + + test('falls back to stack-wide secret when per-workspace bundle has no webhook_signing_secret field', async () => { + // Migration mid-state: workspace was onboarded under the old flow + // (no signing secret on its OAuth bundle), but later got registered. + // Until the user re-runs setup with --rotate-webhook-secret, the + // stack-wide secret remains the source of truth for that workspace. + configureRegistry({ + [WORKSPACE_A_ID]: { oauth_secret_arn: WORKSPACE_A_SECRET_ARN, status: 'active', workspace_slug: 'acme-A' }, + }); + configureSecretsManager({ + [WORKSPACE_A_SECRET_ARN]: makeStoredOauth({ + workspace_id: WORKSPACE_A_ID, + // No webhook_signing_secret field — pre-migration bundle. + }), + [process.env.LINEAR_WEBHOOK_SECRET_ARN!]: STACK_WIDE_SECRET, + }); + const body = payloadFor(WORKSPACE_A_ID); + const result = await handler(makeEvent(body, sign(STACK_WIDE_SECRET, body))); + expect(result.statusCode).toBe(200); + expect(lambdaSend).toHaveBeenCalledTimes(1); + }); + + test('rejects when registry status is not active even if per-workspace secret matches', async () => { + // Revoked workspaces shouldn't trigger tasks. Setting status to + // 'revoked' makes verifyLinearRequestForWorkspace return + // 'no-per-workspace-secret', and the stack-wide fallback then has + // a fresh shot — but here we never installed a stack-wide secret + // matching the workspace's signing secret, so the fallback fails too. + configureRegistry({ + [WORKSPACE_A_ID]: { oauth_secret_arn: WORKSPACE_A_SECRET_ARN, status: 'revoked', workspace_slug: 'acme-A' }, + }); + configureSecretsManager({ + [WORKSPACE_A_SECRET_ARN]: makeStoredOauth({ + workspace_id: WORKSPACE_A_ID, + webhook_signing_secret: WORKSPACE_A_SECRET, + }), + [process.env.LINEAR_WEBHOOK_SECRET_ARN!]: STACK_WIDE_SECRET, + }); + const body = payloadFor(WORKSPACE_A_ID); + const result = await handler(makeEvent(body, sign(WORKSPACE_A_SECRET, body))); + expect(result.statusCode).toBe(401); + expect(lambdaSend).not.toHaveBeenCalled(); + }); +}); diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index c8c2acfa..2c344cc4 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -571,11 +571,44 @@ export function makeLinearCommand(): Command { const adminLabel = identity.viewer.name ?? identity.viewer.email ?? identity.viewer.id; console.log(` ✓ Linked Linear user ${adminLabel} → platform user`); - // ─── Step 6: Webhook signing secret (workspace-independent) ─── - const alreadyConfigured = await isWebhookSecretConfigured(sm, webhookSecretArn!); - - if (alreadyConfigured && !opts.rotateWebhookSecret) { - console.log(' ✓ Webhook signing secret already configured (use --rotate-webhook-secret to update)'); + // ─── Step 6: Webhook signing secret (per-workspace + stack-wide) ─── + // + // Webhook subscriptions in Linear are workspace-scoped, and Linear + // generates a fresh signing secret per subscription. To verify + // events from N workspaces we need N signing secrets, looked up + // by orgId. We store the workspace's signing secret on its OAuth + // bundle (per-workspace path) AND mirror to the stack-wide secret + // (back-compat path) when (a) it's the first install (stack-wide + // is empty), or (b) the user explicitly asked to rotate. + // + // The webhook receiver tries per-workspace first and falls back + // to the stack-wide secret, so existing installs keep working + // without re-onboarding. Multi-workspace installs need each + // workspace to own its own per-workspace signing secret — only + // the FIRST install can populate the stack-wide one usefully. + const stackWideAlreadyConfigured = await isWebhookSecretConfigured(sm, webhookSecretArn!); + let webhookSigningSecret: string | undefined; + + if (stackWideAlreadyConfigured && !opts.rotateWebhookSecret) { + // Two ways into this branch: + // 1. Re-running setup on an existing single-workspace install. + // The stack-wide secret IS this workspace's signing + // secret — seed the per-workspace field from it for + // auto-migration to the new shape. + // 2. Re-running setup on the FIRST workspace of a future + // multi-workspace install. Same story — stack-wide is + // already correct for this workspace. + // In either case, lifting the stack-wide value into the + // per-workspace bundle is correct. + console.log(' ✓ Webhook signing secret already configured stack-wide (mirroring to per-workspace)'); + try { + const value = await sm.send(new GetSecretValueCommand({ SecretId: webhookSecretArn! })); + if (value.SecretString && value.SecretString.startsWith('lin_wh_')) { + webhookSigningSecret = value.SecretString; + } + } catch (err) { + console.log(` ⚠ Could not read stack-wide secret to mirror: ${err instanceof Error ? err.message : String(err)}`); + } } else { const apiBaseUrl = config.api_url.replace(/\/+$/, ''); console.log(); @@ -593,11 +626,33 @@ export function makeLinearCommand(): Command { 'Webhook signing secrets start with \'lin_wh_\'. Got something different — re-check the Linear webhook detail page.', ); } - await sm.send(new PutSecretValueCommand({ - SecretId: webhookSecretArn!, - SecretString: webhookSecret, - })); - console.log(' ✓ Stored webhook signing secret'); + // Stack-wide write is only meaningful for the FIRST install + // (back-compat fallback). Subsequent workspaces would overwrite + // the first workspace's secret, so we only write stack-wide if + // it's not already configured. The per-workspace write below + // is what actually drives multi-workspace verification. + if (!stackWideAlreadyConfigured) { + await sm.send(new PutSecretValueCommand({ + SecretId: webhookSecretArn!, + SecretString: webhookSecret, + })); + console.log(' ✓ Stored webhook signing secret (stack-wide back-compat)'); + } else { + console.log(' ✓ Captured webhook signing secret (per-workspace only — stack-wide left as-is for back-compat)'); + } + webhookSigningSecret = webhookSecret; + } + + // Mirror into the per-workspace OAuth secret so the receiver can + // look it up by orgId. Re-upsert with the merged payload. + if (webhookSigningSecret) { + const merged: StoredLinearOauthToken = { + ...stored, + webhook_signing_secret: webhookSigningSecret, + updated_at: new Date().toISOString(), + }; + await upsertOauthSecret(sm, secretName, merged, slug); + console.log(' ✓ Mirrored signing secret to per-workspace OAuth bundle'); } // ─── Done ────────────────────────────────────────────────────── @@ -856,15 +911,43 @@ export function makeLinearCommand(): Command { const adminLabel = identity.viewer.name ?? identity.viewer.email ?? identity.viewer.id; console.log(` ✓ Linked Linear user ${adminLabel} → platform user`); + // ─── Per-workspace webhook signing secret ────────────────────── + // Linear webhook subscriptions are workspace-scoped, with a fresh + // signing secret per subscription. Each workspace needs to own + // its own signing secret so the receiver can verify by orgId. + // Always prompt — there's no shared secret we can reuse. + const apiBaseUrl = config.api_url.replace(/\/+$/, ''); + console.log(); + console.log(` Webhook signing secret needed for '${slug}'.`); + console.log(` In Linear (signed into '${slug}') → Settings → API → Webhooks, create a webhook pointing at:`); + console.log(` ${apiBaseUrl}/linear/webhook`); + console.log(' Subscribe to: Issues. Copy the signing secret from the webhook detail page.'); + console.log(); + const webhookSigningSecret = (await promptSecret(' Webhook signing secret (lin_wh_…): ')).trim(); + if (!webhookSigningSecret) { + throw new CliError('Webhook signing secret is required.'); + } + if (!webhookSigningSecret.startsWith('lin_wh_')) { + throw new CliError( + 'Webhook signing secrets start with \'lin_wh_\'. Got something different — re-check the Linear webhook detail page.', + ); + } + + // Re-upsert the OAuth secret with the signing secret merged in. + // We don't touch the stack-wide secret here — that's reserved + // for the FIRST install (back-compat fallback). + const merged: StoredLinearOauthToken = { + ...stored, + webhook_signing_secret: webhookSigningSecret, + updated_at: new Date().toISOString(), + }; + await upsertOauthSecret(sm, secretName, merged, slug); + console.log(' ✓ Stored webhook signing secret on per-workspace OAuth bundle'); + // ─── Done ────────────────────────────────────────────────────── console.log(); console.log('✅ Workspace added.'); console.log(); - console.log('Note: webhook signing secret was NOT prompted — it is shared across all'); - console.log('workspaces installed against the same Linear OAuth app. If this is the first'); - console.log('time installing in a new Linear team that has its own OAuth app, run'); - console.log('`bgagent linear setup` instead so the webhook signing secret gets configured.'); - console.log(); console.log('Next: onboard a project from this workspace:'); console.log(' bgagent linear onboard-project --repo owner/repo'); }), diff --git a/cli/src/linear-oauth.ts b/cli/src/linear-oauth.ts index d23e390d..7777525a 100644 --- a/cli/src/linear-oauth.ts +++ b/cli/src/linear-oauth.ts @@ -86,6 +86,19 @@ export interface StoredLinearOauthToken { readonly updated_at: string; /** Cognito sub of the admin who ran `bgagent linear setup`. Audit only. */ readonly installed_by_platform_user_id: string; + /** + * Per-workspace Linear webhook signing secret (`lin_wh_…`). + * + * Linear generates a fresh signing secret per webhook subscription, and + * webhook subscriptions are workspace-scoped — so a single stack-wide + * signing secret can't verify events from multiple workspaces. The + * webhook receiver looks this up by orgId at verify time. + * + * Optional for back-compat: tokens written before the per-workspace + * signing flow won't have it, and the receiver falls back to the + * stack-wide `LINEAR_WEBHOOK_SECRET_ARN` for those installs. + */ + readonly webhook_signing_secret?: string; } /** diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index c84cc4af..41483466 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -78,7 +78,9 @@ While `bgagent linear setup` is paused at the `Webhook signing secret:` prompt, - **Resource types**: check **Issues** only - **Team**: whichever team owns the projects you'll map to ABCA (or all teams) -Save, then open the webhook's detail page and copy the **signing secret** (starts with `lin_wh_`). Paste it back into the terminal where setup is paused. The wizard stores it in `LinearWebhookSecret` (one secret per ABCA stack — shared across all installed workspaces). +Save, then open the webhook's detail page and copy the **signing secret** (starts with `lin_wh_`). Paste it back into the terminal where setup is paused. + +> **Where the signing secret is stored.** `bgagent linear setup` stores the signing secret on the workspace's per-workspace OAuth bundle (`bgagent-linear-oauth-`), where the webhook receiver looks it up by `organizationId` at verify time. On the first install, it's also mirrored into the stack-wide `LinearWebhookSecret` for back-compat with single-workspace deployments — see [How webhook signature verification works](#how-webhook-signature-verification-works) for the full story. > **Re-running setup later** skips the webhook prompt if the signing secret is already configured. Pass `--rotate-webhook-secret` to re-prompt (e.g. after rotating the secret in Linear). @@ -152,10 +154,11 @@ bgagent linear add-workspace This: -- Auto-detects the OAuth app's `client_id`/`client_secret` from any existing active workspace's per-workspace secret (no re-prompt) +- Prompts for the OAuth Client ID — defaults to the existing workspace's value (Enter to reuse, or paste a different one for a per-workspace OAuth app) +- Prompts for the Client Secret if you supplied a new Client ID; otherwise reuses the existing one - Runs the OAuth dance against the new workspace - Creates `bgagent-linear-oauth-` in Secrets Manager and writes a registry row -- **Skips the webhook signing secret prompt** — the same signing secret covers all workspaces against the same ABCA receiver URL +- **Prompts for the webhook signing secret** — Linear generates a fresh signing secret per webhook subscription, and webhook subscriptions are workspace-scoped, so each workspace must configure its own webhook in Linear and bring its own signing secret - Refuses to silently overwrite an already-onboarded workspace's registry row (use `setup` to re-authorize an existing workspace) ### One OAuth app for all workspaces vs. one per workspace @@ -180,6 +183,22 @@ Per-workspace apps give cleaner revocation, per-workspace branding, and isolatio There's no AWS-side ceiling on the number of installable workspaces — each is just an SM secret + DDB row. Practical limits are Linear's API rate limits and per-workspace operator overhead. +## How webhook signature verification works + +Linear generates a fresh signing secret **per webhook subscription**, and webhook subscriptions are **workspace-scoped**. There's no Linear-side mechanism to share one signing secret across multiple workspaces. So multi-workspace ABCA installs need each workspace's signing secret stored separately, indexed by `organizationId` (the workspace UUID embedded in the webhook payload). + +ABCA stores each workspace's signing secret on its per-workspace OAuth bundle (`bgagent-linear-oauth-`, alongside the access/refresh tokens). The webhook receiver runs this verification flow on each event: + +1. Parse the body to extract `organizationId` (untrusted at this point — only used to select which secret to verify against, never trusted before the signature passes). +2. Look up the registry row for that `organizationId`. If `status='active'` and the OAuth bundle has a `webhook_signing_secret` field: + - Verify the HMAC. If it matches → event is trusted, dispatch to the processor. + - If it doesn't match → reject 401. **No fallback** to the stack-wide secret — that would let an attacker bypass the per-workspace secret by signing with whatever the stack-wide one happens to be. +3. If the registry has no row, or the OAuth bundle lacks `webhook_signing_secret` (pre-migration single-workspace install), fall back to the stack-wide `LinearWebhookSecret` and verify against that. If it matches → trusted; if not → 401. + +The fallback path keeps existing single-workspace deployments working without re-onboarding. To migrate a single-workspace install to the per-workspace shape, run `bgagent linear setup --rotate-webhook-secret` once — the wizard will mirror the secret onto the OAuth bundle. + +**Trust model.** The `organizationId` in the body is attacker-controlled — they can claim any workspace. But it only **selects** which secret to verify against; an attacker still needs the matching signing secret to forge a valid signature, which they don't have. Cross-workspace impersonation is prevented by the no-fallback-on-mismatch rule above. + ## Usage ### Trigger a task @@ -228,7 +247,10 @@ Re-run `bgagent linear setup` after fixing. ### Webhook signature verification fails repeatedly -The signing secret in Secrets Manager doesn't match the webhook. Re-run `bgagent linear setup --webhook-secret ` and paste the secret from the webhook's detail page (not the OAuth app page). +Most likely the signing secret stored on this workspace's OAuth bundle doesn't match the webhook subscription that Linear is sending from. Two cases: + +1. **Single-workspace install, signing secret rotated in Linear:** re-run `bgagent linear setup --rotate-webhook-secret` and paste the new value from the Linear webhook detail page. +2. **Multi-workspace install, wrong workspace's secret pasted during onboarding:** check the workspace's secret with `aws secretsmanager get-secret-value --secret-id bgagent-linear-oauth-` and confirm the `webhook_signing_secret` field matches what Linear shows on that workspace's webhook detail page. If wrong, re-run `bgagent linear add-workspace ` (or `setup --rotate-webhook-secret` to re-prompt without re-running the OAuth dance — currently only `setup` supports rotation; multi-workspace rotation is a planned enhancement). ## Migration from 2.0a (PAK) to 2.0b (OAuth) diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index b9e1ce4a..0fde750a 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -82,7 +82,9 @@ While `bgagent linear setup` is paused at the `Webhook signing secret:` prompt, - **Resource types**: check **Issues** only - **Team**: whichever team owns the projects you'll map to ABCA (or all teams) -Save, then open the webhook's detail page and copy the **signing secret** (starts with `lin_wh_`). Paste it back into the terminal where setup is paused. The wizard stores it in `LinearWebhookSecret` (one secret per ABCA stack — shared across all installed workspaces). +Save, then open the webhook's detail page and copy the **signing secret** (starts with `lin_wh_`). Paste it back into the terminal where setup is paused. + +> **Where the signing secret is stored.** `bgagent linear setup` stores the signing secret on the workspace's per-workspace OAuth bundle (`bgagent-linear-oauth-`), where the webhook receiver looks it up by `organizationId` at verify time. On the first install, it's also mirrored into the stack-wide `LinearWebhookSecret` for back-compat with single-workspace deployments — see [How webhook signature verification works](#how-webhook-signature-verification-works) for the full story. > **Re-running setup later** skips the webhook prompt if the signing secret is already configured. Pass `--rotate-webhook-secret` to re-prompt (e.g. after rotating the secret in Linear). @@ -156,10 +158,11 @@ bgagent linear add-workspace This: -- Auto-detects the OAuth app's `client_id`/`client_secret` from any existing active workspace's per-workspace secret (no re-prompt) +- Prompts for the OAuth Client ID — defaults to the existing workspace's value (Enter to reuse, or paste a different one for a per-workspace OAuth app) +- Prompts for the Client Secret if you supplied a new Client ID; otherwise reuses the existing one - Runs the OAuth dance against the new workspace - Creates `bgagent-linear-oauth-` in Secrets Manager and writes a registry row -- **Skips the webhook signing secret prompt** — the same signing secret covers all workspaces against the same ABCA receiver URL +- **Prompts for the webhook signing secret** — Linear generates a fresh signing secret per webhook subscription, and webhook subscriptions are workspace-scoped, so each workspace must configure its own webhook in Linear and bring its own signing secret - Refuses to silently overwrite an already-onboarded workspace's registry row (use `setup` to re-authorize an existing workspace) ### One OAuth app for all workspaces vs. one per workspace @@ -184,6 +187,22 @@ Per-workspace apps give cleaner revocation, per-workspace branding, and isolatio There's no AWS-side ceiling on the number of installable workspaces — each is just an SM secret + DDB row. Practical limits are Linear's API rate limits and per-workspace operator overhead. +## How webhook signature verification works + +Linear generates a fresh signing secret **per webhook subscription**, and webhook subscriptions are **workspace-scoped**. There's no Linear-side mechanism to share one signing secret across multiple workspaces. So multi-workspace ABCA installs need each workspace's signing secret stored separately, indexed by `organizationId` (the workspace UUID embedded in the webhook payload). + +ABCA stores each workspace's signing secret on its per-workspace OAuth bundle (`bgagent-linear-oauth-`, alongside the access/refresh tokens). The webhook receiver runs this verification flow on each event: + +1. Parse the body to extract `organizationId` (untrusted at this point — only used to select which secret to verify against, never trusted before the signature passes). +2. Look up the registry row for that `organizationId`. If `status='active'` and the OAuth bundle has a `webhook_signing_secret` field: + - Verify the HMAC. If it matches → event is trusted, dispatch to the processor. + - If it doesn't match → reject 401. **No fallback** to the stack-wide secret — that would let an attacker bypass the per-workspace secret by signing with whatever the stack-wide one happens to be. +3. If the registry has no row, or the OAuth bundle lacks `webhook_signing_secret` (pre-migration single-workspace install), fall back to the stack-wide `LinearWebhookSecret` and verify against that. If it matches → trusted; if not → 401. + +The fallback path keeps existing single-workspace deployments working without re-onboarding. To migrate a single-workspace install to the per-workspace shape, run `bgagent linear setup --rotate-webhook-secret` once — the wizard will mirror the secret onto the OAuth bundle. + +**Trust model.** The `organizationId` in the body is attacker-controlled — they can claim any workspace. But it only **selects** which secret to verify against; an attacker still needs the matching signing secret to forge a valid signature, which they don't have. Cross-workspace impersonation is prevented by the no-fallback-on-mismatch rule above. + ## Usage ### Trigger a task @@ -232,7 +251,10 @@ Re-run `bgagent linear setup` after fixing. ### Webhook signature verification fails repeatedly -The signing secret in Secrets Manager doesn't match the webhook. Re-run `bgagent linear setup --webhook-secret ` and paste the secret from the webhook's detail page (not the OAuth app page). +Most likely the signing secret stored on this workspace's OAuth bundle doesn't match the webhook subscription that Linear is sending from. Two cases: + +1. **Single-workspace install, signing secret rotated in Linear:** re-run `bgagent linear setup --rotate-webhook-secret` and paste the new value from the Linear webhook detail page. +2. **Multi-workspace install, wrong workspace's secret pasted during onboarding:** check the workspace's secret with `aws secretsmanager get-secret-value --secret-id bgagent-linear-oauth-` and confirm the `webhook_signing_secret` field matches what Linear shows on that workspace's webhook detail page. If wrong, re-run `bgagent linear add-workspace ` (or `setup --rotate-webhook-secret` to re-prompt without re-running the OAuth dance — currently only `setup` supports rotation; multi-workspace rotation is a planned enhancement). ## Migration from 2.0a (PAK) to 2.0b (OAuth) From 1b3105012bc8665dc1ff366d24f8b4fe191f4f66 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 22:22:56 -0400 Subject: [PATCH 023/190] feat(linear): bgagent linear update-webhook-secret for rotation/recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The OAuth dance can't be re-run when an app is already installed in a Linear workspace — Linear returns access_denied. That makes \`setup --rotate-webhook-secret\` and \`add-workspace\` both unusable for the common case of "this workspace works, but the webhook signing secret needs to change." Use cases: 1. Rotation (security policy, planned cycle, key compromise) 2. Recovery from misconfig (typed wrong, copied from wrong page) 3. First-time set after Linear regenerated the signing secret on webhook recreation The new command: - Reads the existing per-workspace OAuth bundle from SM - Prompts for the new signing secret (validates lin_wh_ prefix) - Re-upserts the bundle with merged webhook_signing_secret + bumped updated_at What it doesn't do: OAuth dance, DDB writes, stack-wide secret writes, Linear API calls. Just the SM mutation. Per-workspace only — mirrors the architectural choice from the multi-workspace fix that the stack-wide secret is reserved for the FIRST install's back-compat fallback. Docs: troubleshooting section now points at this command for "webhook signature verification fails repeatedly" — the most common production path. The previous guidance to re-run setup --rotate-webhook-secret remains the right primitive for single-workspace deploys that haven't fully migrated. --- cli/src/commands/linear.ts | 88 +++++++++++++++++++ docs/guides/LINEAR_SETUP_GUIDE.md | 20 ++++- .../content/docs/using/Linear-setup-guide.md | 20 ++++- 3 files changed, 122 insertions(+), 6 deletions(-) diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 2c344cc4..3df52e77 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -953,6 +953,94 @@ export function makeLinearCommand(): Command { }), ); + linear.addCommand( + new Command('update-webhook-secret') + .description('Update the per-workspace webhook signing secret without re-running OAuth') + .argument('', 'Linear workspace urlKey (e.g. "acme" from linear.app/acme/...)') + .option('--region ', 'AWS region (defaults to configured region)') + .action(async (slug: string, opts) => { + // Use case: rotation, recovery from misconfig, or first-time + // configuration after Linear regenerated the signing secret. + // The OAuth dance can't be re-run when the app is already + // installed in the workspace (Linear returns access_denied), + // so this command sidesteps it entirely — read the existing + // OAuth bundle, swap the signing-secret field, write it back. + if (!SLUG_RE.test(slug)) { + throw new CliError( + `Invalid workspace slug '${slug}'. Must be 4-50 chars matching [a-zA-Z0-9_-]. ` + + 'This is the Linear urlKey, e.g. \'acme\' from linear.app/acme/...', + ); + } + const config = loadConfig(); + const region = opts.region || config.region; + + const sm = new SecretsManagerClient({ region }); + const secretName = linearOauthSecretName(slug); + + // ─── Read existing bundle ─────────────────────────────────── + let stored: StoredLinearOauthToken; + try { + const value = await sm.send(new GetSecretValueCommand({ SecretId: secretName })); + if (!value.SecretString) { + throw new CliError( + `Secret '${secretName}' has no SecretString. Run \`bgagent linear setup ${slug}\` to install fresh.`, + ); + } + stored = JSON.parse(value.SecretString) as StoredLinearOauthToken; + } catch (err) { + const errorName = (err as { name?: string }).name; + if (errorName === 'ResourceNotFoundException') { + throw new CliError( + `Workspace '${slug}' is not installed (no Secrets Manager secret '${secretName}'). ` + + `Run \`bgagent linear setup ${slug}\` or \`bgagent linear add-workspace ${slug}\` first.`, + ); + } + if (err instanceof CliError) throw err; + throw new CliError( + `Could not read existing OAuth bundle: ${err instanceof Error ? err.message : String(err)}`, + ); + } + if (!stored.access_token || !stored.workspace_id) { + throw new CliError( + `Secret '${secretName}' is missing required fields (access_token / workspace_id). ` + + `Bundle may be corrupted; re-run \`bgagent linear setup ${slug}\` to rebuild.`, + ); + } + + console.log(`bgagent linear update-webhook-secret — workspace '${slug}'`); + console.log(` region: ${region}`); + console.log(` current webhook_signing_secret: ${stored.webhook_signing_secret ? 'set' : 'not set'}`); + console.log(); + console.log(' Paste the new signing secret from Linear → Settings → API → Webhooks'); + console.log(` (signed into '${slug}'). Open the webhook detail page and copy the signing secret.`); + console.log(); + + // ─── Prompt for new secret ────────────────────────────────── + const webhookSigningSecret = (await promptSecret(' Webhook signing secret (lin_wh_…): ')).trim(); + if (!webhookSigningSecret) { + throw new CliError('Webhook signing secret is required.'); + } + if (!webhookSigningSecret.startsWith('lin_wh_')) { + throw new CliError( + 'Webhook signing secrets start with \'lin_wh_\'. Got something different — re-check the Linear webhook detail page.', + ); + } + + // ─── Write back ───────────────────────────────────────────── + const merged: StoredLinearOauthToken = { + ...stored, + webhook_signing_secret: webhookSigningSecret, + updated_at: new Date().toISOString(), + }; + await upsertOauthSecret(sm, secretName, merged, slug); + + console.log(); + console.log(`✅ Updated webhook signing secret for '${slug}'.`); + console.log(); + console.log('Next webhook event from this workspace will verify against the new secret.'); + }), + ); + linear.addCommand( new Command('onboard-project') .description('Map a Linear project to a GitHub repository (admin IAM required)') diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index 41483466..5c9a4809 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -247,10 +247,24 @@ Re-run `bgagent linear setup` after fixing. ### Webhook signature verification fails repeatedly -Most likely the signing secret stored on this workspace's OAuth bundle doesn't match the webhook subscription that Linear is sending from. Two cases: +Most likely the signing secret stored on this workspace's OAuth bundle doesn't match the webhook subscription that Linear is sending from. Run: -1. **Single-workspace install, signing secret rotated in Linear:** re-run `bgagent linear setup --rotate-webhook-secret` and paste the new value from the Linear webhook detail page. -2. **Multi-workspace install, wrong workspace's secret pasted during onboarding:** check the workspace's secret with `aws secretsmanager get-secret-value --secret-id bgagent-linear-oauth-` and confirm the `webhook_signing_secret` field matches what Linear shows on that workspace's webhook detail page. If wrong, re-run `bgagent linear add-workspace ` (or `setup --rotate-webhook-secret` to re-prompt without re-running the OAuth dance — currently only `setup` supports rotation; multi-workspace rotation is a planned enhancement). +```bash +bgagent linear update-webhook-secret +``` + +Paste the current signing secret from Linear's webhook detail page. This works for any installed workspace — it skips the OAuth dance entirely (Linear refuses to re-issue codes for already-installed apps) and just updates the per-workspace `webhook_signing_secret` field on the SM bundle. + +To inspect what's currently stored: + +```bash +aws secretsmanager get-secret-value --secret-id bgagent-linear-oauth- --query SecretString --output text | jq .webhook_signing_secret +``` + +Other failure modes: + +- **You rotated the signing secret in Linear but never updated ABCA** — same fix as above. +- **You're running multi-workspace and the wrong webhook (from a different workspace) is targeting your ABCA endpoint** — check the `organizationId` field in the failing webhook's payload (CloudWatch log on the receiver Lambda) against the registry table. If it doesn't match any registered workspace and the stack-wide secret also doesn't match, you have a webhook configured in a Linear workspace you haven't onboarded — either onboard it via `add-workspace` or remove the webhook in Linear. ## Migration from 2.0a (PAK) to 2.0b (OAuth) diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index 0fde750a..fd00d424 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -251,10 +251,24 @@ Re-run `bgagent linear setup` after fixing. ### Webhook signature verification fails repeatedly -Most likely the signing secret stored on this workspace's OAuth bundle doesn't match the webhook subscription that Linear is sending from. Two cases: +Most likely the signing secret stored on this workspace's OAuth bundle doesn't match the webhook subscription that Linear is sending from. Run: -1. **Single-workspace install, signing secret rotated in Linear:** re-run `bgagent linear setup --rotate-webhook-secret` and paste the new value from the Linear webhook detail page. -2. **Multi-workspace install, wrong workspace's secret pasted during onboarding:** check the workspace's secret with `aws secretsmanager get-secret-value --secret-id bgagent-linear-oauth-` and confirm the `webhook_signing_secret` field matches what Linear shows on that workspace's webhook detail page. If wrong, re-run `bgagent linear add-workspace ` (or `setup --rotate-webhook-secret` to re-prompt without re-running the OAuth dance — currently only `setup` supports rotation; multi-workspace rotation is a planned enhancement). +```bash +bgagent linear update-webhook-secret +``` + +Paste the current signing secret from Linear's webhook detail page. This works for any installed workspace — it skips the OAuth dance entirely (Linear refuses to re-issue codes for already-installed apps) and just updates the per-workspace `webhook_signing_secret` field on the SM bundle. + +To inspect what's currently stored: + +```bash +aws secretsmanager get-secret-value --secret-id bgagent-linear-oauth- --query SecretString --output text | jq .webhook_signing_secret +``` + +Other failure modes: + +- **You rotated the signing secret in Linear but never updated ABCA** — same fix as above. +- **You're running multi-workspace and the wrong webhook (from a different workspace) is targeting your ABCA endpoint** — check the `organizationId` field in the failing webhook's payload (CloudWatch log on the receiver Lambda) against the registry table. If it doesn't match any registered workspace and the stack-wide secret also doesn't match, you have a webhook configured in a Linear workspace you haven't onboarded — either onboard it via `add-workspace` or remove the webhook in Linear. ## Migration from 2.0a (PAK) to 2.0b (OAuth) From ac5ce67551d0e10a0d4c880d7bb27f18d21c72ec Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 22:27:28 -0400 Subject: [PATCH 024/190] refactor(linear): drop --rotate-webhook-secret in favor of update-webhook-secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The flag's job — re-prompt for the signing secret on an already-installed workspace — is now done better by \`bgagent linear update-webhook-secret\`, which skips the OAuth dance entirely. Keeping the flag means two tools that do nearly the same thing, and forcing the user to redo OAuth just to type a new signing secret is wasteful. setup's webhook flow simplifies to: stack-wide already set → mirror into per-workspace bundle (auto-migration); else prompt + write to both. No conditional flag-based branching. Docs updated: - Step 3 footnote points at update-webhook-secret for rotation - 'How webhook signature verification works' single-workspace migration note: setup auto-mirrors on next run, no flag needed --- cli/src/commands/linear.ts | 43 ++++++++----------- docs/guides/LINEAR_SETUP_GUIDE.md | 4 +- .../content/docs/using/Linear-setup-guide.md | 4 +- 3 files changed, 21 insertions(+), 30 deletions(-) diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 3df52e77..6c722798 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -355,7 +355,6 @@ export function makeLinearCommand(): Command { .option('--client-id ', 'Linear OAuth app Client ID (else prompted)') .option('--client-secret ', 'Linear OAuth app Client Secret (else prompted; prefer interactive)') .option('--no-browser', 'Print the authorization URL instead of opening a browser (for SSH/headless)') - .option('--rotate-webhook-secret', 'Re-prompt for the webhook signing secret even if one is already configured') .option('--no-actor-app', 'Drop actor=app from the OAuth flow (diagnostic: isolates whether agent-install is blocking)') .action(async (slug: string, opts) => { if (!SLUG_RE.test(slug)) { @@ -586,20 +585,18 @@ export function makeLinearCommand(): Command { // without re-onboarding. Multi-workspace installs need each // workspace to own its own per-workspace signing secret — only // the FIRST install can populate the stack-wide one usefully. + // If stack-wide is already populated, this is either a re-run + // of setup on the SAME workspace or the FIRST workspace of a + // future multi-workspace install. Either way the stored value + // is this workspace's signing secret — lift it into the + // per-workspace bundle without prompting (auto-migration to + // the new shape). Rotation is not setup's job: use + // `bgagent linear update-webhook-secret ` to rotate the + // signing secret without re-running OAuth. const stackWideAlreadyConfigured = await isWebhookSecretConfigured(sm, webhookSecretArn!); let webhookSigningSecret: string | undefined; - if (stackWideAlreadyConfigured && !opts.rotateWebhookSecret) { - // Two ways into this branch: - // 1. Re-running setup on an existing single-workspace install. - // The stack-wide secret IS this workspace's signing - // secret — seed the per-workspace field from it for - // auto-migration to the new shape. - // 2. Re-running setup on the FIRST workspace of a future - // multi-workspace install. Same story — stack-wide is - // already correct for this workspace. - // In either case, lifting the stack-wide value into the - // per-workspace bundle is correct. + if (stackWideAlreadyConfigured) { console.log(' ✓ Webhook signing secret already configured stack-wide (mirroring to per-workspace)'); try { const value = await sm.send(new GetSecretValueCommand({ SecretId: webhookSecretArn! })); @@ -626,20 +623,14 @@ export function makeLinearCommand(): Command { 'Webhook signing secrets start with \'lin_wh_\'. Got something different — re-check the Linear webhook detail page.', ); } - // Stack-wide write is only meaningful for the FIRST install - // (back-compat fallback). Subsequent workspaces would overwrite - // the first workspace's secret, so we only write stack-wide if - // it's not already configured. The per-workspace write below - // is what actually drives multi-workspace verification. - if (!stackWideAlreadyConfigured) { - await sm.send(new PutSecretValueCommand({ - SecretId: webhookSecretArn!, - SecretString: webhookSecret, - })); - console.log(' ✓ Stored webhook signing secret (stack-wide back-compat)'); - } else { - console.log(' ✓ Captured webhook signing secret (per-workspace only — stack-wide left as-is for back-compat)'); - } + // First install: stamp BOTH stack-wide (back-compat fallback + // for installs predating per-workspace signing) and the + // per-workspace OAuth bundle (the verifier's primary path). + await sm.send(new PutSecretValueCommand({ + SecretId: webhookSecretArn!, + SecretString: webhookSecret, + })); + console.log(' ✓ Stored webhook signing secret (stack-wide back-compat)'); webhookSigningSecret = webhookSecret; } diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index 5c9a4809..761e642d 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -82,7 +82,7 @@ Save, then open the webhook's detail page and copy the **signing secret** (start > **Where the signing secret is stored.** `bgagent linear setup` stores the signing secret on the workspace's per-workspace OAuth bundle (`bgagent-linear-oauth-`), where the webhook receiver looks it up by `organizationId` at verify time. On the first install, it's also mirrored into the stack-wide `LinearWebhookSecret` for back-compat with single-workspace deployments — see [How webhook signature verification works](#how-webhook-signature-verification-works) for the full story. -> **Re-running setup later** skips the webhook prompt if the signing secret is already configured. Pass `--rotate-webhook-secret` to re-prompt (e.g. after rotating the secret in Linear). +> **Re-running setup later** skips the webhook prompt if the signing secret is already configured. To rotate the signing secret without re-running the OAuth dance, use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly). ### Step 4: Onboard a Linear project @@ -195,7 +195,7 @@ ABCA stores each workspace's signing secret on its per-workspace OAuth bundle (` - If it doesn't match → reject 401. **No fallback** to the stack-wide secret — that would let an attacker bypass the per-workspace secret by signing with whatever the stack-wide one happens to be. 3. If the registry has no row, or the OAuth bundle lacks `webhook_signing_secret` (pre-migration single-workspace install), fall back to the stack-wide `LinearWebhookSecret` and verify against that. If it matches → trusted; if not → 401. -The fallback path keeps existing single-workspace deployments working without re-onboarding. To migrate a single-workspace install to the per-workspace shape, run `bgagent linear setup --rotate-webhook-secret` once — the wizard will mirror the secret onto the OAuth bundle. +The fallback path keeps existing single-workspace deployments working without re-onboarding. The migration to the per-workspace shape happens automatically the next time you run `bgagent linear setup ` — it reads the existing stack-wide secret and mirrors it onto the workspace's OAuth bundle without re-prompting. **Trust model.** The `organizationId` in the body is attacker-controlled — they can claim any workspace. But it only **selects** which secret to verify against; an attacker still needs the matching signing secret to forge a valid signature, which they don't have. Cross-workspace impersonation is prevented by the no-fallback-on-mismatch rule above. diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index fd00d424..0cc23599 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -86,7 +86,7 @@ Save, then open the webhook's detail page and copy the **signing secret** (start > **Where the signing secret is stored.** `bgagent linear setup` stores the signing secret on the workspace's per-workspace OAuth bundle (`bgagent-linear-oauth-`), where the webhook receiver looks it up by `organizationId` at verify time. On the first install, it's also mirrored into the stack-wide `LinearWebhookSecret` for back-compat with single-workspace deployments — see [How webhook signature verification works](#how-webhook-signature-verification-works) for the full story. -> **Re-running setup later** skips the webhook prompt if the signing secret is already configured. Pass `--rotate-webhook-secret` to re-prompt (e.g. after rotating the secret in Linear). +> **Re-running setup later** skips the webhook prompt if the signing secret is already configured. To rotate the signing secret without re-running the OAuth dance, use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly). ### Step 4: Onboard a Linear project @@ -199,7 +199,7 @@ ABCA stores each workspace's signing secret on its per-workspace OAuth bundle (` - If it doesn't match → reject 401. **No fallback** to the stack-wide secret — that would let an attacker bypass the per-workspace secret by signing with whatever the stack-wide one happens to be. 3. If the registry has no row, or the OAuth bundle lacks `webhook_signing_secret` (pre-migration single-workspace install), fall back to the stack-wide `LinearWebhookSecret` and verify against that. If it matches → trusted; if not → 401. -The fallback path keeps existing single-workspace deployments working without re-onboarding. To migrate a single-workspace install to the per-workspace shape, run `bgagent linear setup --rotate-webhook-secret` once — the wizard will mirror the secret onto the OAuth bundle. +The fallback path keeps existing single-workspace deployments working without re-onboarding. The migration to the per-workspace shape happens automatically the next time you run `bgagent linear setup ` — it reads the existing stack-wide secret and mirrors it onto the workspace's OAuth bundle without re-prompting. **Trust model.** The `organizationId` in the body is attacker-controlled — they can claim any workspace. But it only **selects** which secret to verify against; an attacker still needs the matching signing secret to forge a valid signature, which they don't have. Cross-workspace impersonation is prevented by the no-fallback-on-mismatch rule above. From f7e403d12f99e9011f47060c23589ce309428ce9 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 23:10:05 -0400 Subject: [PATCH 025/190] docs(linear): step-by-step walkthrough for adding a second workspace The 'Adding additional Linear workspaces' section listed operations as bullets but didn't sequence them in an actionable way. Multi- workspace onboarding crosses three contexts (CLI, Linear OAuth app config, Linear webhook config) and bullet-lists left it unclear which steps to do where, in what order. New layout: - Brief 'Decide: shared vs per-workspace OAuth app' table up front - Numbered walkthrough that interleaves the CLI + Linear browser work in the order you actually need to do them, including the pause-at-prompt-then-switch-to-browser step for the webhook - Two FAQs at the end ('what if I skip step 4?' and 'what if I typed the signing secret wrong?') for the common gotchas Also drops the stale --client-id / --client-secret command examples that referenced the flags removed in ac5ce67. The walkthrough now points the user at the interactive prompts directly. --- docs/guides/LINEAR_SETUP_GUIDE.md | 86 ++++++++++++++----- .../content/docs/using/Linear-setup-guide.md | 86 ++++++++++++++----- 2 files changed, 132 insertions(+), 40 deletions(-) diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index 761e642d..ce50b359 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -146,42 +146,88 @@ Add the `bgagent` label to a Linear issue in a mapped project. Within a few seco ## Adding additional Linear workspaces -A single ABCA deployment can serve multiple Linear workspaces. Once you've completed initial `bgagent linear setup` for one workspace, additional workspaces use the lighter `add-workspace` command: +A single ABCA deployment can serve multiple Linear workspaces. Once you've completed initial `bgagent linear setup` for one workspace, additional workspaces use the lighter `add-workspace` command — one OAuth dance per workspace plus per-workspace webhook configuration. There's no AWS-side ceiling on the number of installable workspaces (each is just an SM secret + DDB row); practical limits are Linear's API rate limits and per-workspace operator overhead. + +### Decide: one OAuth app for all workspaces, or one per workspace? + +Linear OAuth apps are **workspace-scoped at install time**: + +- A **private** Linear OAuth app (default) can only be authorized from the workspace that created it. Trying to install it in a second workspace returns `Could not find OAuth client with clientId `. +- A **public** Linear OAuth app can be authorized from any workspace by anyone with the install URL. The client_secret is still yours; "public" only means "anyone can run the consent flow." For a self-hosted ABCA install this is usually fine. + +Pick one approach before continuing: + +| Approach | When to use | Downside | +|---|---|---| +| **A: Single shared OAuth app (Public: ON)** | Personal demos, single-org setups | All workspaces revoke together | +| **B: Separate OAuth app per workspace** | Multi-org or production setups | More Linear configuration per workspace | + +For Option A, edit your existing OAuth app in Linear settings and toggle **Public: ON** *before* running the steps below. + +### Step-by-step walkthrough + +**1. Decide the workspace ``.** + +The slug is the URL key from `https://linear.app//...`. Find it in Linear → Settings → Workspace → URL key, or just look at any URL while logged into the workspace. + +**2. (Option B only) Create a new Linear OAuth app in the new workspace.** + +If you chose Option B, sign into the new workspace in your browser (Linear sidebar workspace switcher) and create a new OAuth app: + +- Open: `https://linear.app//settings/api/applications/new` +- Fill in the fields per `bgagent linear app-template`. Important values: + - **Callback URLs**: `http://localhost:8080/oauth/callback` + - **GitHub username**: must end with `[bot]` (e.g. `bgagent[bot]`) + - **Webhooks**: ON, URL `https://example.com/placeholder` (placeholder, real URL configured in step 4) +- Save, copy the Client ID and Client Secret — you'll paste them in step 3. + +**3. Run `bgagent linear add-workspace`.** ```bash -bgagent linear add-workspace +bgagent linear add-workspace ``` -This: +The CLI: -- Prompts for the OAuth Client ID — defaults to the existing workspace's value (Enter to reuse, or paste a different one for a per-workspace OAuth app) -- Prompts for the Client Secret if you supplied a new Client ID; otherwise reuses the existing one -- Runs the OAuth dance against the new workspace -- Creates `bgagent-linear-oauth-` in Secrets Manager and writes a registry row -- **Prompts for the webhook signing secret** — Linear generates a fresh signing secret per webhook subscription, and webhook subscriptions are workspace-scoped, so each workspace must configure its own webhook in Linear and bring its own signing secret -- Refuses to silently overwrite an already-onboarded workspace's registry row (use `setup` to re-authorize an existing workspace) +- Looks up the existing workspace and shows its OAuth Client ID as a default in `[brackets]` +- For Option A: press Enter to reuse the existing app +- For Option B: paste the new Client ID, then paste the new Client Secret when prompted +- Opens your browser to Linear's consent screen — **make sure you're signed into the new workspace** (use Linear's workspace switcher if needed) +- Authorize the app +- The CLI writes the per-workspace OAuth secret + registry row, then pauses at the `Webhook signing secret:` prompt -### One OAuth app for all workspaces vs. one per workspace +**4. Configure the workspace's webhook in Linear (in a new tab).** -Linear OAuth apps are **workspace-scoped at install time**: +While `add-workspace` is paused at the signing-secret prompt: -- A **private** Linear OAuth app (default) can only be authorized from the workspace that created it. Trying to install it in a second workspace returns `Could not find OAuth client with clientId `. -- A **public** Linear OAuth app can be authorized from any workspace by anyone with the install URL. The client_secret is still yours; "public" only means "anyone can run the consent flow." For a self-hosted ABCA install this is usually fine. +- Open: `https://linear.app//settings/api/webhooks` → **+ New webhook** +- **URL**: your stack's API endpoint, e.g. `https://.execute-api..amazonaws.com/v1/linear/webhook` — find this in your CloudFormation stack's `ApiUrl` output +- **Resource types**: check **Issues** only +- **Team**: whichever team owns the projects you'll map (or all teams) +- Save, then open the webhook detail page and copy the `lin_wh_…` signing secret -Pick one of: +**5. Paste the signing secret back into the CLI.** -**Option A: Single shared OAuth app (recommended for personal demos and single-org setups).** In your initial workspace's Linear settings, edit the OAuth app and toggle **Public: ON**. Then `bgagent linear add-workspace ` works without `--client-id`. Cleanest UX, single point of revocation. +The CLI stores it on the workspace's per-workspace OAuth bundle. The webhook receiver looks it up by `organizationId` at verify time — see [How webhook signature verification works](#how-webhook-signature-verification-works) for the trust model. -**Option B: Separate OAuth app per workspace (recommended for multi-org / production setups).** Create a new OAuth app in each new workspace's Linear settings (Step 1 above), then pass the new credentials explicitly: +**6. Onboard a project from the new workspace.** ```bash -bgagent linear add-workspace \ - --client-id --client-secret +bgagent linear list-projects --slug # find the project UUID +bgagent linear onboard-project --repo owner/repo --label abca ``` -Per-workspace apps give cleaner revocation, per-workspace branding, and isolation if one workspace's credentials leak. Each new app needs its own callback URL (`http://localhost:8080/oauth/callback`) and its own `bgagent[bot]` GitHub username. +**7. Test.** + +Apply the trigger label (`abca` in the example above) to a Linear issue in the onboarded project. The agent should start within ~30 seconds. + +### What if I skip step 4 (webhook configuration in Linear)? + +The OAuth install completes successfully, but Linear has no webhook to send events from. Triggering an issue in that workspace is silent — no events reach your API endpoint, no agent runs. Configuring the webhook is what makes the workspace actually trigger ABCA. + +### What if I made a mistake on the signing secret? -There's no AWS-side ceiling on the number of installable workspaces — each is just an SM secret + DDB row. Practical limits are Linear's API rate limits and per-workspace operator overhead. +Use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly) to re-prompt and overwrite. Doesn't re-run the OAuth dance. ## How webhook signature verification works diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index 0cc23599..0873fe57 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -150,42 +150,88 @@ Add the `bgagent` label to a Linear issue in a mapped project. Within a few seco ## Adding additional Linear workspaces -A single ABCA deployment can serve multiple Linear workspaces. Once you've completed initial `bgagent linear setup` for one workspace, additional workspaces use the lighter `add-workspace` command: +A single ABCA deployment can serve multiple Linear workspaces. Once you've completed initial `bgagent linear setup` for one workspace, additional workspaces use the lighter `add-workspace` command — one OAuth dance per workspace plus per-workspace webhook configuration. There's no AWS-side ceiling on the number of installable workspaces (each is just an SM secret + DDB row); practical limits are Linear's API rate limits and per-workspace operator overhead. + +### Decide: one OAuth app for all workspaces, or one per workspace? + +Linear OAuth apps are **workspace-scoped at install time**: + +- A **private** Linear OAuth app (default) can only be authorized from the workspace that created it. Trying to install it in a second workspace returns `Could not find OAuth client with clientId `. +- A **public** Linear OAuth app can be authorized from any workspace by anyone with the install URL. The client_secret is still yours; "public" only means "anyone can run the consent flow." For a self-hosted ABCA install this is usually fine. + +Pick one approach before continuing: + +| Approach | When to use | Downside | +|---|---|---| +| **A: Single shared OAuth app (Public: ON)** | Personal demos, single-org setups | All workspaces revoke together | +| **B: Separate OAuth app per workspace** | Multi-org or production setups | More Linear configuration per workspace | + +For Option A, edit your existing OAuth app in Linear settings and toggle **Public: ON** *before* running the steps below. + +### Step-by-step walkthrough + +**1. Decide the workspace ``.** + +The slug is the URL key from `https://linear.app//...`. Find it in Linear → Settings → Workspace → URL key, or just look at any URL while logged into the workspace. + +**2. (Option B only) Create a new Linear OAuth app in the new workspace.** + +If you chose Option B, sign into the new workspace in your browser (Linear sidebar workspace switcher) and create a new OAuth app: + +- Open: `https://linear.app//settings/api/applications/new` +- Fill in the fields per `bgagent linear app-template`. Important values: + - **Callback URLs**: `http://localhost:8080/oauth/callback` + - **GitHub username**: must end with `[bot]` (e.g. `bgagent[bot]`) + - **Webhooks**: ON, URL `https://example.com/placeholder` (placeholder, real URL configured in step 4) +- Save, copy the Client ID and Client Secret — you'll paste them in step 3. + +**3. Run `bgagent linear add-workspace`.** ```bash -bgagent linear add-workspace +bgagent linear add-workspace ``` -This: +The CLI: -- Prompts for the OAuth Client ID — defaults to the existing workspace's value (Enter to reuse, or paste a different one for a per-workspace OAuth app) -- Prompts for the Client Secret if you supplied a new Client ID; otherwise reuses the existing one -- Runs the OAuth dance against the new workspace -- Creates `bgagent-linear-oauth-` in Secrets Manager and writes a registry row -- **Prompts for the webhook signing secret** — Linear generates a fresh signing secret per webhook subscription, and webhook subscriptions are workspace-scoped, so each workspace must configure its own webhook in Linear and bring its own signing secret -- Refuses to silently overwrite an already-onboarded workspace's registry row (use `setup` to re-authorize an existing workspace) +- Looks up the existing workspace and shows its OAuth Client ID as a default in `[brackets]` +- For Option A: press Enter to reuse the existing app +- For Option B: paste the new Client ID, then paste the new Client Secret when prompted +- Opens your browser to Linear's consent screen — **make sure you're signed into the new workspace** (use Linear's workspace switcher if needed) +- Authorize the app +- The CLI writes the per-workspace OAuth secret + registry row, then pauses at the `Webhook signing secret:` prompt -### One OAuth app for all workspaces vs. one per workspace +**4. Configure the workspace's webhook in Linear (in a new tab).** -Linear OAuth apps are **workspace-scoped at install time**: +While `add-workspace` is paused at the signing-secret prompt: -- A **private** Linear OAuth app (default) can only be authorized from the workspace that created it. Trying to install it in a second workspace returns `Could not find OAuth client with clientId `. -- A **public** Linear OAuth app can be authorized from any workspace by anyone with the install URL. The client_secret is still yours; "public" only means "anyone can run the consent flow." For a self-hosted ABCA install this is usually fine. +- Open: `https://linear.app//settings/api/webhooks` → **+ New webhook** +- **URL**: your stack's API endpoint, e.g. `https://.execute-api..amazonaws.com/v1/linear/webhook` — find this in your CloudFormation stack's `ApiUrl` output +- **Resource types**: check **Issues** only +- **Team**: whichever team owns the projects you'll map (or all teams) +- Save, then open the webhook detail page and copy the `lin_wh_…` signing secret -Pick one of: +**5. Paste the signing secret back into the CLI.** -**Option A: Single shared OAuth app (recommended for personal demos and single-org setups).** In your initial workspace's Linear settings, edit the OAuth app and toggle **Public: ON**. Then `bgagent linear add-workspace ` works without `--client-id`. Cleanest UX, single point of revocation. +The CLI stores it on the workspace's per-workspace OAuth bundle. The webhook receiver looks it up by `organizationId` at verify time — see [How webhook signature verification works](#how-webhook-signature-verification-works) for the trust model. -**Option B: Separate OAuth app per workspace (recommended for multi-org / production setups).** Create a new OAuth app in each new workspace's Linear settings (Step 1 above), then pass the new credentials explicitly: +**6. Onboard a project from the new workspace.** ```bash -bgagent linear add-workspace \ - --client-id --client-secret +bgagent linear list-projects --slug # find the project UUID +bgagent linear onboard-project --repo owner/repo --label abca ``` -Per-workspace apps give cleaner revocation, per-workspace branding, and isolation if one workspace's credentials leak. Each new app needs its own callback URL (`http://localhost:8080/oauth/callback`) and its own `bgagent[bot]` GitHub username. +**7. Test.** + +Apply the trigger label (`abca` in the example above) to a Linear issue in the onboarded project. The agent should start within ~30 seconds. + +### What if I skip step 4 (webhook configuration in Linear)? + +The OAuth install completes successfully, but Linear has no webhook to send events from. Triggering an issue in that workspace is silent — no events reach your API endpoint, no agent runs. Configuring the webhook is what makes the workspace actually trigger ABCA. + +### What if I made a mistake on the signing secret? -There's no AWS-side ceiling on the number of installable workspaces — each is just an SM secret + DDB row. Practical limits are Linear's API rate limits and per-workspace operator overhead. +Use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly) to re-prompt and overwrite. Doesn't re-run the OAuth dance. ## How webhook signature verification works From 5debf600e7495250a9400cfd4b18586ad50cb3f2 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 23:10:05 -0400 Subject: [PATCH 026/190] docs(linear): step-by-step walkthrough for adding a second workspace The 'Adding additional Linear workspaces' section listed operations as bullets but didn't sequence them in an actionable way. Multi- workspace onboarding crosses three contexts (CLI, Linear OAuth app config, Linear webhook config) and bullet-lists left it unclear which steps to do where, in what order. New layout: - Brief 'Decide: shared vs per-workspace OAuth app' table up front - Numbered walkthrough that interleaves the CLI + Linear browser work in the order you actually need to do them, including the pause-at-prompt-then-switch-to-browser step for the webhook - Two FAQs at the end ('what if I skip step 4?' and 'what if I typed the signing secret wrong?') for the common gotchas Also drops the stale --client-id / --client-secret command examples that referenced the flags removed in ac5ce67. The walkthrough now points the user at the interactive prompts directly. --- docs/guides/LINEAR_SETUP_GUIDE.md | 86 ++++++++++++++----- .../content/docs/using/Linear-setup-guide.md | 86 ++++++++++++++----- 2 files changed, 132 insertions(+), 40 deletions(-) diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index 761e642d..ce50b359 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -146,42 +146,88 @@ Add the `bgagent` label to a Linear issue in a mapped project. Within a few seco ## Adding additional Linear workspaces -A single ABCA deployment can serve multiple Linear workspaces. Once you've completed initial `bgagent linear setup` for one workspace, additional workspaces use the lighter `add-workspace` command: +A single ABCA deployment can serve multiple Linear workspaces. Once you've completed initial `bgagent linear setup` for one workspace, additional workspaces use the lighter `add-workspace` command — one OAuth dance per workspace plus per-workspace webhook configuration. There's no AWS-side ceiling on the number of installable workspaces (each is just an SM secret + DDB row); practical limits are Linear's API rate limits and per-workspace operator overhead. + +### Decide: one OAuth app for all workspaces, or one per workspace? + +Linear OAuth apps are **workspace-scoped at install time**: + +- A **private** Linear OAuth app (default) can only be authorized from the workspace that created it. Trying to install it in a second workspace returns `Could not find OAuth client with clientId `. +- A **public** Linear OAuth app can be authorized from any workspace by anyone with the install URL. The client_secret is still yours; "public" only means "anyone can run the consent flow." For a self-hosted ABCA install this is usually fine. + +Pick one approach before continuing: + +| Approach | When to use | Downside | +|---|---|---| +| **A: Single shared OAuth app (Public: ON)** | Personal demos, single-org setups | All workspaces revoke together | +| **B: Separate OAuth app per workspace** | Multi-org or production setups | More Linear configuration per workspace | + +For Option A, edit your existing OAuth app in Linear settings and toggle **Public: ON** *before* running the steps below. + +### Step-by-step walkthrough + +**1. Decide the workspace ``.** + +The slug is the URL key from `https://linear.app//...`. Find it in Linear → Settings → Workspace → URL key, or just look at any URL while logged into the workspace. + +**2. (Option B only) Create a new Linear OAuth app in the new workspace.** + +If you chose Option B, sign into the new workspace in your browser (Linear sidebar workspace switcher) and create a new OAuth app: + +- Open: `https://linear.app//settings/api/applications/new` +- Fill in the fields per `bgagent linear app-template`. Important values: + - **Callback URLs**: `http://localhost:8080/oauth/callback` + - **GitHub username**: must end with `[bot]` (e.g. `bgagent[bot]`) + - **Webhooks**: ON, URL `https://example.com/placeholder` (placeholder, real URL configured in step 4) +- Save, copy the Client ID and Client Secret — you'll paste them in step 3. + +**3. Run `bgagent linear add-workspace`.** ```bash -bgagent linear add-workspace +bgagent linear add-workspace ``` -This: +The CLI: -- Prompts for the OAuth Client ID — defaults to the existing workspace's value (Enter to reuse, or paste a different one for a per-workspace OAuth app) -- Prompts for the Client Secret if you supplied a new Client ID; otherwise reuses the existing one -- Runs the OAuth dance against the new workspace -- Creates `bgagent-linear-oauth-` in Secrets Manager and writes a registry row -- **Prompts for the webhook signing secret** — Linear generates a fresh signing secret per webhook subscription, and webhook subscriptions are workspace-scoped, so each workspace must configure its own webhook in Linear and bring its own signing secret -- Refuses to silently overwrite an already-onboarded workspace's registry row (use `setup` to re-authorize an existing workspace) +- Looks up the existing workspace and shows its OAuth Client ID as a default in `[brackets]` +- For Option A: press Enter to reuse the existing app +- For Option B: paste the new Client ID, then paste the new Client Secret when prompted +- Opens your browser to Linear's consent screen — **make sure you're signed into the new workspace** (use Linear's workspace switcher if needed) +- Authorize the app +- The CLI writes the per-workspace OAuth secret + registry row, then pauses at the `Webhook signing secret:` prompt -### One OAuth app for all workspaces vs. one per workspace +**4. Configure the workspace's webhook in Linear (in a new tab).** -Linear OAuth apps are **workspace-scoped at install time**: +While `add-workspace` is paused at the signing-secret prompt: -- A **private** Linear OAuth app (default) can only be authorized from the workspace that created it. Trying to install it in a second workspace returns `Could not find OAuth client with clientId `. -- A **public** Linear OAuth app can be authorized from any workspace by anyone with the install URL. The client_secret is still yours; "public" only means "anyone can run the consent flow." For a self-hosted ABCA install this is usually fine. +- Open: `https://linear.app//settings/api/webhooks` → **+ New webhook** +- **URL**: your stack's API endpoint, e.g. `https://.execute-api..amazonaws.com/v1/linear/webhook` — find this in your CloudFormation stack's `ApiUrl` output +- **Resource types**: check **Issues** only +- **Team**: whichever team owns the projects you'll map (or all teams) +- Save, then open the webhook detail page and copy the `lin_wh_…` signing secret -Pick one of: +**5. Paste the signing secret back into the CLI.** -**Option A: Single shared OAuth app (recommended for personal demos and single-org setups).** In your initial workspace's Linear settings, edit the OAuth app and toggle **Public: ON**. Then `bgagent linear add-workspace ` works without `--client-id`. Cleanest UX, single point of revocation. +The CLI stores it on the workspace's per-workspace OAuth bundle. The webhook receiver looks it up by `organizationId` at verify time — see [How webhook signature verification works](#how-webhook-signature-verification-works) for the trust model. -**Option B: Separate OAuth app per workspace (recommended for multi-org / production setups).** Create a new OAuth app in each new workspace's Linear settings (Step 1 above), then pass the new credentials explicitly: +**6. Onboard a project from the new workspace.** ```bash -bgagent linear add-workspace \ - --client-id --client-secret +bgagent linear list-projects --slug # find the project UUID +bgagent linear onboard-project --repo owner/repo --label abca ``` -Per-workspace apps give cleaner revocation, per-workspace branding, and isolation if one workspace's credentials leak. Each new app needs its own callback URL (`http://localhost:8080/oauth/callback`) and its own `bgagent[bot]` GitHub username. +**7. Test.** + +Apply the trigger label (`abca` in the example above) to a Linear issue in the onboarded project. The agent should start within ~30 seconds. + +### What if I skip step 4 (webhook configuration in Linear)? + +The OAuth install completes successfully, but Linear has no webhook to send events from. Triggering an issue in that workspace is silent — no events reach your API endpoint, no agent runs. Configuring the webhook is what makes the workspace actually trigger ABCA. + +### What if I made a mistake on the signing secret? -There's no AWS-side ceiling on the number of installable workspaces — each is just an SM secret + DDB row. Practical limits are Linear's API rate limits and per-workspace operator overhead. +Use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly) to re-prompt and overwrite. Doesn't re-run the OAuth dance. ## How webhook signature verification works diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index 0cc23599..0873fe57 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -150,42 +150,88 @@ Add the `bgagent` label to a Linear issue in a mapped project. Within a few seco ## Adding additional Linear workspaces -A single ABCA deployment can serve multiple Linear workspaces. Once you've completed initial `bgagent linear setup` for one workspace, additional workspaces use the lighter `add-workspace` command: +A single ABCA deployment can serve multiple Linear workspaces. Once you've completed initial `bgagent linear setup` for one workspace, additional workspaces use the lighter `add-workspace` command — one OAuth dance per workspace plus per-workspace webhook configuration. There's no AWS-side ceiling on the number of installable workspaces (each is just an SM secret + DDB row); practical limits are Linear's API rate limits and per-workspace operator overhead. + +### Decide: one OAuth app for all workspaces, or one per workspace? + +Linear OAuth apps are **workspace-scoped at install time**: + +- A **private** Linear OAuth app (default) can only be authorized from the workspace that created it. Trying to install it in a second workspace returns `Could not find OAuth client with clientId `. +- A **public** Linear OAuth app can be authorized from any workspace by anyone with the install URL. The client_secret is still yours; "public" only means "anyone can run the consent flow." For a self-hosted ABCA install this is usually fine. + +Pick one approach before continuing: + +| Approach | When to use | Downside | +|---|---|---| +| **A: Single shared OAuth app (Public: ON)** | Personal demos, single-org setups | All workspaces revoke together | +| **B: Separate OAuth app per workspace** | Multi-org or production setups | More Linear configuration per workspace | + +For Option A, edit your existing OAuth app in Linear settings and toggle **Public: ON** *before* running the steps below. + +### Step-by-step walkthrough + +**1. Decide the workspace ``.** + +The slug is the URL key from `https://linear.app//...`. Find it in Linear → Settings → Workspace → URL key, or just look at any URL while logged into the workspace. + +**2. (Option B only) Create a new Linear OAuth app in the new workspace.** + +If you chose Option B, sign into the new workspace in your browser (Linear sidebar workspace switcher) and create a new OAuth app: + +- Open: `https://linear.app//settings/api/applications/new` +- Fill in the fields per `bgagent linear app-template`. Important values: + - **Callback URLs**: `http://localhost:8080/oauth/callback` + - **GitHub username**: must end with `[bot]` (e.g. `bgagent[bot]`) + - **Webhooks**: ON, URL `https://example.com/placeholder` (placeholder, real URL configured in step 4) +- Save, copy the Client ID and Client Secret — you'll paste them in step 3. + +**3. Run `bgagent linear add-workspace`.** ```bash -bgagent linear add-workspace +bgagent linear add-workspace ``` -This: +The CLI: -- Prompts for the OAuth Client ID — defaults to the existing workspace's value (Enter to reuse, or paste a different one for a per-workspace OAuth app) -- Prompts for the Client Secret if you supplied a new Client ID; otherwise reuses the existing one -- Runs the OAuth dance against the new workspace -- Creates `bgagent-linear-oauth-` in Secrets Manager and writes a registry row -- **Prompts for the webhook signing secret** — Linear generates a fresh signing secret per webhook subscription, and webhook subscriptions are workspace-scoped, so each workspace must configure its own webhook in Linear and bring its own signing secret -- Refuses to silently overwrite an already-onboarded workspace's registry row (use `setup` to re-authorize an existing workspace) +- Looks up the existing workspace and shows its OAuth Client ID as a default in `[brackets]` +- For Option A: press Enter to reuse the existing app +- For Option B: paste the new Client ID, then paste the new Client Secret when prompted +- Opens your browser to Linear's consent screen — **make sure you're signed into the new workspace** (use Linear's workspace switcher if needed) +- Authorize the app +- The CLI writes the per-workspace OAuth secret + registry row, then pauses at the `Webhook signing secret:` prompt -### One OAuth app for all workspaces vs. one per workspace +**4. Configure the workspace's webhook in Linear (in a new tab).** -Linear OAuth apps are **workspace-scoped at install time**: +While `add-workspace` is paused at the signing-secret prompt: -- A **private** Linear OAuth app (default) can only be authorized from the workspace that created it. Trying to install it in a second workspace returns `Could not find OAuth client with clientId `. -- A **public** Linear OAuth app can be authorized from any workspace by anyone with the install URL. The client_secret is still yours; "public" only means "anyone can run the consent flow." For a self-hosted ABCA install this is usually fine. +- Open: `https://linear.app//settings/api/webhooks` → **+ New webhook** +- **URL**: your stack's API endpoint, e.g. `https://.execute-api..amazonaws.com/v1/linear/webhook` — find this in your CloudFormation stack's `ApiUrl` output +- **Resource types**: check **Issues** only +- **Team**: whichever team owns the projects you'll map (or all teams) +- Save, then open the webhook detail page and copy the `lin_wh_…` signing secret -Pick one of: +**5. Paste the signing secret back into the CLI.** -**Option A: Single shared OAuth app (recommended for personal demos and single-org setups).** In your initial workspace's Linear settings, edit the OAuth app and toggle **Public: ON**. Then `bgagent linear add-workspace ` works without `--client-id`. Cleanest UX, single point of revocation. +The CLI stores it on the workspace's per-workspace OAuth bundle. The webhook receiver looks it up by `organizationId` at verify time — see [How webhook signature verification works](#how-webhook-signature-verification-works) for the trust model. -**Option B: Separate OAuth app per workspace (recommended for multi-org / production setups).** Create a new OAuth app in each new workspace's Linear settings (Step 1 above), then pass the new credentials explicitly: +**6. Onboard a project from the new workspace.** ```bash -bgagent linear add-workspace \ - --client-id --client-secret +bgagent linear list-projects --slug # find the project UUID +bgagent linear onboard-project --repo owner/repo --label abca ``` -Per-workspace apps give cleaner revocation, per-workspace branding, and isolation if one workspace's credentials leak. Each new app needs its own callback URL (`http://localhost:8080/oauth/callback`) and its own `bgagent[bot]` GitHub username. +**7. Test.** + +Apply the trigger label (`abca` in the example above) to a Linear issue in the onboarded project. The agent should start within ~30 seconds. + +### What if I skip step 4 (webhook configuration in Linear)? + +The OAuth install completes successfully, but Linear has no webhook to send events from. Triggering an issue in that workspace is silent — no events reach your API endpoint, no agent runs. Configuring the webhook is what makes the workspace actually trigger ABCA. + +### What if I made a mistake on the signing secret? -There's no AWS-side ceiling on the number of installable workspaces — each is just an SM secret + DDB row. Practical limits are Linear's API rate limits and per-workspace operator overhead. +Use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly) to re-prompt and overwrite. Doesn't re-run the OAuth dance. ## How webhook signature verification works From 68e6010ffd4f719f886a0d33050536d939804c2e Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 23:35:20 -0400 Subject: [PATCH 027/190] feat(linear): bgagent linear webhook-info + setup-guide trim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The setup guide kept telling users to find the API URL in CFN outputs or substitute their own region/account into a placeholder. The CLI already has the URL — make it surface it. \`bgagent linear webhook-info\` reads config.api_url and prints the webhook URL plus the values to paste into Linear's webhook UI, plus the followup command (\`update-webhook-secret\`). Read-only, no AWS calls beyond what the existing config layer already does. Setup guide trimmed: - Step 1: removed the parked-flow footnote (fixed at the source by defaulting app-template's callback URL to http://localhost:8080/ oauth/callback instead of the parked AgentCore Identity placeholder) - Step 2: collapsed the 6-bullet wizard description into 2 sentences that describe what the user actually does, dropping the \`--client-id\` / \`--client-secret\` flag mention (those flags never existed on setup; they were on add-workspace and got removed earlier this branch) - Step 3: now references webhook-info as the single source for what to paste into Linear, dropping the embedded URL template and CFN output instructions - Multi-workspace step 4: same — references webhook-info instead of embedding a URL template - Dropped two long callout blocks that explained internals operators don't need at setup time (where the OAuth token lives, where the signing secret is mirrored — covered in 'How webhook signature verification works' for those who want it) Net: -51 lines from the guide, one new always-printable command, no more URL substitution by the user. --- cli/src/commands/linear.ts | 52 ++++++++++++++++--- cli/test/commands/linear.test.ts | 12 +++-- docs/guides/LINEAR_SETUP_GUIDE.md | 51 ++++++------------ .../content/docs/using/Linear-setup-guide.md | 51 ++++++------------ 4 files changed, 86 insertions(+), 80 deletions(-) diff --git a/cli/src/commands/linear.ts b/cli/src/commands/linear.ts index 6c722798..9717fc5b 100644 --- a/cli/src/commands/linear.ts +++ b/cli/src/commands/linear.ts @@ -73,11 +73,13 @@ export function renderLinearAppTemplate(opts: LinearAppTemplateOptions = {}): st const developerName = opts.developerName ?? 'ABCA'; const developerUrl = opts.developerUrl ?? 'https://github.com/aws-samples/sample-autonomous-cloud-coding-agents'; const description = opts.description ?? 'Autonomous Background Coding Agent'; - // The AWS-hosted callback is surfaced by `aws bedrock-agentcore-control - // create-oauth2-credential-provider` once per workspace. If unknown at - // template-render time, print a placeholder the operator must replace. - const awsCallback = opts.awsCallbackUrl - ?? ''; + // Phase 2.0b-O2 (shipped) uses a localhost callback that + // `bgagent linear setup` listens on for the one-time redirect. The + // `awsCallbackUrl` option is retained for the parked Phase 2.0a flow + // and (rare) operators forwarding the callback through a fixed + // upstream URL — but the localhost default works for everyone running + // setup interactively from their machine. + const callbackUrl = opts.awsCallbackUrl ?? 'http://localhost:8080/oauth/callback'; const bar = '═'.repeat(72); return [ @@ -93,7 +95,7 @@ export function renderLinearAppTemplate(opts: LinearAppTemplateOptions = {}): st ` Description: ${description}`, '', ' Callback URLs (one per line, NO line wrapping):', - ` ${awsCallback}`, + ` ${callbackUrl}`, '', ` GitHub username: ${botName} ← REQUIRED for actor=app`, ' Public: OFF', @@ -326,6 +328,44 @@ export function makeLinearCommand(): Command { }), ); + linear.addCommand( + new Command('webhook-info') + .description('Print the webhook URL + Linear settings for this stack') + .action(() => { + // Read-only convenience — surfaces the values an operator needs to + // create a webhook subscription in Linear (URL, resource types, + // followup command). Eliminates the "find the API URL in CFN + // outputs" detour that the setup guide used to embed. + const config = loadConfig(); + if (!config.api_url) { + throw new CliError( + 'No API URL configured. Run `bgagent configure` first to point at a deployed stack.', + ); + } + const webhookUrl = `${config.api_url.replace(/\/+$/, '')}/linear/webhook`; + const bar = '═'.repeat(72); + console.log(bar); + console.log('Linear webhook configuration'); + console.log(bar); + console.log(); + console.log('In Linear → Settings → API → Webhooks → + New webhook, paste:'); + console.log(); + console.log(` URL: ${webhookUrl}`); + console.log(' Resource types: Issues'); + console.log(' Team: (whichever team owns the projects you map)'); + console.log(); + console.log('Save, then open the webhook detail page and copy the signing secret'); + console.log('(starts with `lin_wh_`). Feed it to ABCA via:'); + console.log(); + console.log(' bgagent linear update-webhook-secret '); + console.log(); + console.log('Note: webhook subscriptions are workspace-scoped, with a fresh signing'); + console.log('secret per subscription. Each Linear workspace you onboard needs its'); + console.log('own webhook configured this way.'); + console.log(bar); + }), + ); + linear.addCommand( new Command('link') .description('Link your Linear account using a verification code') diff --git a/cli/test/commands/linear.test.ts b/cli/test/commands/linear.test.ts index aa9f53d0..8b9d4636 100644 --- a/cli/test/commands/linear.test.ts +++ b/cli/test/commands/linear.test.ts @@ -160,16 +160,20 @@ describe('renderLinearAppTemplate', () => { expect(out).toContain('REQUIRED for actor=app'); }); - test('includes the AWS callback URL placeholder when not provided', () => { + test('defaults the callback URL to the localhost endpoint that setup listens on', () => { + // Phase 2.0b-O2 (shipped) uses an ephemeral localhost server during + // `bgagent linear setup`. Printing the right URL by default + // eliminates the "and now substitute the placeholder" step the + // setup guide used to embed. const out = renderLinearAppTemplate(); - expect(out).toContain(''); + expect(out).toContain('http://localhost:8080/oauth/callback'); }); - test('substitutes the AWS callback URL when supplied', () => { + test('substitutes a different callback URL when supplied (parked AgentCore Identity flow)', () => { const url = 'https://bedrock-agentcore.us-east-1.amazonaws.com/identities/oauth2/callback/abc-123'; const out = renderLinearAppTemplate({ awsCallbackUrl: url }); expect(out).toContain(url); - expect(out).not.toContain(' { diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index ce50b359..408ba2e2 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -37,14 +37,7 @@ Run: bgagent linear app-template ``` -This prints the exact field values to paste into Linear's OAuth app form. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) (make sure you're signed into the workspace where you want the app to live — use Linear's workspace switcher in the sidebar if needed) and fill in the fields the template lists. Critical fields (each gates the `actor=app` agent flow — without them Linear surfaces a misleading "Invalid redirect_uri" error): - -- **GitHub username**: must end with the literal `[bot]` suffix (e.g., `bgagent[bot]`) -- **Webhooks**: toggle ON (the URL value can be a placeholder; we don't subscribe to events for the OAuth flow itself) -- **Callback URLs**: `http://localhost:8080/oauth/callback` — the localhost server `bgagent linear setup` listens on for the redirect. Wildcards are not accepted; if you serve setup from multiple machines, register each callback URL fully. -- **Public**: leave OFF unless you plan to install this app in multiple Linear workspaces — see [Adding additional Linear workspaces](#adding-additional-linear-workspaces) for the trade-offs. - -> **Note.** The `app-template` command currently prints a placeholder for the AWS-hosted callback URL referencing the parked Phase 2.0a flow. The actual callback for the shipped Phase 2.0b-O2 flow is `http://localhost:8080/oauth/callback`. The template will be updated to print this value once the parked path is removed; for now, override the placeholder when you paste into Linear. +The command prints the exact field values to paste. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) (make sure you're signed into the right workspace — use Linear's workspace switcher in the sidebar) and fill in the fields exactly as the template lists. The template marks which fields are required for the `actor=app` agent flow; missing them produces a cryptic "Invalid redirect_uri" error. Click **Save**, then copy the **Client ID** and **Client Secret** from the app's detail page. @@ -56,33 +49,21 @@ bgagent linear setup Where `` is the Linear `urlKey` of the workspace (e.g. `acme` from `https://linear.app/acme/...`). -The wizard: - -1. Prompts for the **Client ID** and **Client Secret** you copied at the end of Step 1 (or pass them via `--client-id` / `--client-secret`). -2. Generates a PKCE code verifier + challenge and starts an ephemeral HTTP server on `localhost:8080` to listen for the callback. -3. Opens Linear's authorization URL in your browser. **Make sure your browser is currently signed into the right workspace** (use Linear's workspace switcher if needed); this is the workspace the app is being installed in. -4. You authorize the OAuth app on the Linear consent screen — Linear redirects to `http://localhost:8080/oauth/callback?code=...&state=...`. -5. The wizard exchanges the code for an `access_token` + `refresh_token`, queries Linear's `viewer { id, organization { id, urlKey } }`, and: - - Creates `bgagent-linear-oauth-` in Secrets Manager with the full token bundle (access, refresh, expires_at, scope, client_id, client_secret, workspace metadata). - - Writes a row into `LinearWorkspaceRegistryTable` with `(linear_workspace_id, workspace_slug, oauth_secret_arn, status='active')`. - - Auto-links you in `LinearUserMappingTable` so tasks you trigger via Linear get attributed to your Cognito user. -6. Then prompts for the **webhook signing secret** — see Step 3 below for where to find it. +The wizard prompts for the **Client ID** and **Client Secret** from Step 1, opens your browser to Linear's consent screen, captures the redirect, and stores the OAuth token bundle in Secrets Manager. **Make sure your browser is signed into the right workspace** before authorizing — that's where the app gets installed. -> **Where the OAuth token lives.** Stored in Secrets Manager at `bgagent-linear-oauth-`, with `client_id` + `client_secret` co-located in the same secret so Lambda-side refresh works without per-Lambda env vars. Lambdas refresh on demand and write the rotated token back; the agent runtime has read-only access (S1 hardening — untrusted repo code can't overwrite tokens). +When the OAuth dance finishes, the wizard pauses at a `Webhook signing secret:` prompt. Move on to Step 3 in a second terminal. ### Step 3: Configure the Linear webhook -While `bgagent linear setup` is paused at the `Webhook signing secret:` prompt, open [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+**: +While `bgagent linear setup` is paused at the `Webhook signing secret:` prompt, open a second terminal and run: -- **URL**: `https://.execute-api..amazonaws.com/v1/linear/webhook` — find this in the CloudFormation stack's `ApiUrl` output, or look up your API Gateway in the AWS console -- **Resource types**: check **Issues** only -- **Team**: whichever team owns the projects you'll map to ABCA (or all teams) - -Save, then open the webhook's detail page and copy the **signing secret** (starts with `lin_wh_`). Paste it back into the terminal where setup is paused. +```bash +bgagent linear webhook-info +``` -> **Where the signing secret is stored.** `bgagent linear setup` stores the signing secret on the workspace's per-workspace OAuth bundle (`bgagent-linear-oauth-`), where the webhook receiver looks it up by `organizationId` at verify time. On the first install, it's also mirrored into the stack-wide `LinearWebhookSecret` for back-compat with single-workspace deployments — see [How webhook signature verification works](#how-webhook-signature-verification-works) for the full story. +It prints the webhook URL for your stack and the values to paste into Linear. Open [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+** and follow the printed instructions. Then open the webhook's detail page, copy the **signing secret** (starts with `lin_wh_`), and paste it back into the first terminal where `setup` is waiting. -> **Re-running setup later** skips the webhook prompt if the signing secret is already configured. To rotate the signing secret without re-running the OAuth dance, use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly). +> **Rotating the signing secret later** — use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly), not `setup`. It updates just the secret without re-running OAuth. ### Step 4: Onboard a Linear project @@ -196,15 +177,15 @@ The CLI: - Authorize the app - The CLI writes the per-workspace OAuth secret + registry row, then pauses at the `Webhook signing secret:` prompt -**4. Configure the workspace's webhook in Linear (in a new tab).** +**4. Configure the workspace's webhook in Linear.** + +While `add-workspace` is paused at the signing-secret prompt, open a second terminal: -While `add-workspace` is paused at the signing-secret prompt: +```bash +bgagent linear webhook-info +``` -- Open: `https://linear.app//settings/api/webhooks` → **+ New webhook** -- **URL**: your stack's API endpoint, e.g. `https://.execute-api..amazonaws.com/v1/linear/webhook` — find this in your CloudFormation stack's `ApiUrl` output -- **Resource types**: check **Issues** only -- **Team**: whichever team owns the projects you'll map (or all teams) -- Save, then open the webhook detail page and copy the `lin_wh_…` signing secret +It prints the URL and values to paste. Open `https://linear.app//settings/api/webhooks`, create the webhook with those values, then copy the `lin_wh_…` signing secret from the webhook detail page. **5. Paste the signing secret back into the CLI.** diff --git a/docs/src/content/docs/using/Linear-setup-guide.md b/docs/src/content/docs/using/Linear-setup-guide.md index 0873fe57..c9227978 100644 --- a/docs/src/content/docs/using/Linear-setup-guide.md +++ b/docs/src/content/docs/using/Linear-setup-guide.md @@ -41,14 +41,7 @@ Run: bgagent linear app-template ``` -This prints the exact field values to paste into Linear's OAuth app form. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) (make sure you're signed into the workspace where you want the app to live — use Linear's workspace switcher in the sidebar if needed) and fill in the fields the template lists. Critical fields (each gates the `actor=app` agent flow — without them Linear surfaces a misleading "Invalid redirect_uri" error): - -- **GitHub username**: must end with the literal `[bot]` suffix (e.g., `bgagent[bot]`) -- **Webhooks**: toggle ON (the URL value can be a placeholder; we don't subscribe to events for the OAuth flow itself) -- **Callback URLs**: `http://localhost:8080/oauth/callback` — the localhost server `bgagent linear setup` listens on for the redirect. Wildcards are not accepted; if you serve setup from multiple machines, register each callback URL fully. -- **Public**: leave OFF unless you plan to install this app in multiple Linear workspaces — see [Adding additional Linear workspaces](#adding-additional-linear-workspaces) for the trade-offs. - -> **Note.** The `app-template` command currently prints a placeholder for the AWS-hosted callback URL referencing the parked Phase 2.0a flow. The actual callback for the shipped Phase 2.0b-O2 flow is `http://localhost:8080/oauth/callback`. The template will be updated to print this value once the parked path is removed; for now, override the placeholder when you paste into Linear. +The command prints the exact field values to paste. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) (make sure you're signed into the right workspace — use Linear's workspace switcher in the sidebar) and fill in the fields exactly as the template lists. The template marks which fields are required for the `actor=app` agent flow; missing them produces a cryptic "Invalid redirect_uri" error. Click **Save**, then copy the **Client ID** and **Client Secret** from the app's detail page. @@ -60,33 +53,21 @@ bgagent linear setup Where `` is the Linear `urlKey` of the workspace (e.g. `acme` from `https://linear.app/acme/...`). -The wizard: - -1. Prompts for the **Client ID** and **Client Secret** you copied at the end of Step 1 (or pass them via `--client-id` / `--client-secret`). -2. Generates a PKCE code verifier + challenge and starts an ephemeral HTTP server on `localhost:8080` to listen for the callback. -3. Opens Linear's authorization URL in your browser. **Make sure your browser is currently signed into the right workspace** (use Linear's workspace switcher if needed); this is the workspace the app is being installed in. -4. You authorize the OAuth app on the Linear consent screen — Linear redirects to `http://localhost:8080/oauth/callback?code=...&state=...`. -5. The wizard exchanges the code for an `access_token` + `refresh_token`, queries Linear's `viewer { id, organization { id, urlKey } }`, and: - - Creates `bgagent-linear-oauth-` in Secrets Manager with the full token bundle (access, refresh, expires_at, scope, client_id, client_secret, workspace metadata). - - Writes a row into `LinearWorkspaceRegistryTable` with `(linear_workspace_id, workspace_slug, oauth_secret_arn, status='active')`. - - Auto-links you in `LinearUserMappingTable` so tasks you trigger via Linear get attributed to your Cognito user. -6. Then prompts for the **webhook signing secret** — see Step 3 below for where to find it. +The wizard prompts for the **Client ID** and **Client Secret** from Step 1, opens your browser to Linear's consent screen, captures the redirect, and stores the OAuth token bundle in Secrets Manager. **Make sure your browser is signed into the right workspace** before authorizing — that's where the app gets installed. -> **Where the OAuth token lives.** Stored in Secrets Manager at `bgagent-linear-oauth-`, with `client_id` + `client_secret` co-located in the same secret so Lambda-side refresh works without per-Lambda env vars. Lambdas refresh on demand and write the rotated token back; the agent runtime has read-only access (S1 hardening — untrusted repo code can't overwrite tokens). +When the OAuth dance finishes, the wizard pauses at a `Webhook signing secret:` prompt. Move on to Step 3 in a second terminal. ### Step 3: Configure the Linear webhook -While `bgagent linear setup` is paused at the `Webhook signing secret:` prompt, open [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+**: +While `bgagent linear setup` is paused at the `Webhook signing secret:` prompt, open a second terminal and run: -- **URL**: `https://.execute-api..amazonaws.com/v1/linear/webhook` — find this in the CloudFormation stack's `ApiUrl` output, or look up your API Gateway in the AWS console -- **Resource types**: check **Issues** only -- **Team**: whichever team owns the projects you'll map to ABCA (or all teams) - -Save, then open the webhook's detail page and copy the **signing secret** (starts with `lin_wh_`). Paste it back into the terminal where setup is paused. +```bash +bgagent linear webhook-info +``` -> **Where the signing secret is stored.** `bgagent linear setup` stores the signing secret on the workspace's per-workspace OAuth bundle (`bgagent-linear-oauth-`), where the webhook receiver looks it up by `organizationId` at verify time. On the first install, it's also mirrored into the stack-wide `LinearWebhookSecret` for back-compat with single-workspace deployments — see [How webhook signature verification works](#how-webhook-signature-verification-works) for the full story. +It prints the webhook URL for your stack and the values to paste into Linear. Open [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+** and follow the printed instructions. Then open the webhook's detail page, copy the **signing secret** (starts with `lin_wh_`), and paste it back into the first terminal where `setup` is waiting. -> **Re-running setup later** skips the webhook prompt if the signing secret is already configured. To rotate the signing secret without re-running the OAuth dance, use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly). +> **Rotating the signing secret later** — use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly), not `setup`. It updates just the secret without re-running OAuth. ### Step 4: Onboard a Linear project @@ -200,15 +181,15 @@ The CLI: - Authorize the app - The CLI writes the per-workspace OAuth secret + registry row, then pauses at the `Webhook signing secret:` prompt -**4. Configure the workspace's webhook in Linear (in a new tab).** +**4. Configure the workspace's webhook in Linear.** + +While `add-workspace` is paused at the signing-secret prompt, open a second terminal: -While `add-workspace` is paused at the signing-secret prompt: +```bash +bgagent linear webhook-info +``` -- Open: `https://linear.app//settings/api/webhooks` → **+ New webhook** -- **URL**: your stack's API endpoint, e.g. `https://.execute-api..amazonaws.com/v1/linear/webhook` — find this in your CloudFormation stack's `ApiUrl` output -- **Resource types**: check **Issues** only -- **Team**: whichever team owns the projects you'll map (or all teams) -- Save, then open the webhook detail page and copy the `lin_wh_…` signing secret +It prints the URL and values to paste. Open `https://linear.app//settings/api/webhooks`, create the webhook with those values, then copy the `lin_wh_…` signing secret from the webhook detail page. **5. Paste the signing secret back into the CLI.** From 1e259a3c044e240c6d8136ca3ca4c2afd01c3114 Mon Sep 17 00:00:00 2001 From: Sphia Sadek Date: Tue, 26 May 2026 23:54:26 -0400 Subject: [PATCH 028/190] docs(linear): aggressive trim of setup guide; PAK migration moved to runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LINEAR_SETUP_GUIDE.md was 405 lines and described the same flow twice (once for first-install single-workspace, once for multi-workspace). The two walkthroughs only differed in `setup` vs `add-workspace`, which is a single-line CLI difference. The PAK migration block was 46 lines for a release that's already shipped — anyone reading the guide today is on 2.0b. Net result: 405 → 215 lines (-47%). One unified walkthrough that calls out the setup-vs-add-workspace branch at step 3, drops dead sections (Out-of-scope items, "What's coming next" — neither was load-bearing), folds the link-your-Linear-account step into a short section near the end since most users hit the auto-link path anyway. Net changes: - One walkthrough instead of two (single-workspace + multi-workspace collapsed; option A/B trade-off comes up at the relevant step). - PAK migration runbook moved to LINEAR_PAK_MIGRATION_RUNBOOK.md + registered in astro sidebar so it's still findable but doesn't block-quote the main guide. - Dropped "Out of scope in v1.x" + "Limits and budgets" tables — the rate-limit info is now one paragraph, the rest was noise. - Fixed "Removing the integration" — it was using the parked AgentCore-Identity delete-oauth2-credential-provider call. Now uses Secrets Manager + DDB directly. - Dropped the "Linear actor has no linked platform user" cross-link (was self-referential to the previous Step 5). --- docs/astro.config.mjs | 1 + docs/guides/LINEAR_PAK_MIGRATION_RUNBOOK.md | 62 +++ docs/guides/LINEAR_SETUP_GUIDE.md | 360 +++++------------- docs/scripts/sync-starlight.mjs | 7 + .../using/Linear-pak-migration-runbook.md | 66 ++++ .../content/docs/using/Linear-setup-guide.md | 360 +++++------------- 6 files changed, 312 insertions(+), 544 deletions(-) create mode 100644 docs/guides/LINEAR_PAK_MIGRATION_RUNBOOK.md create mode 100644 docs/src/content/docs/using/Linear-pak-migration-runbook.md diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 4bbe89e4..9f14b2d8 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -56,6 +56,7 @@ export default defineConfig({ { slug: 'using/webhook-integration' }, { slug: 'using/slack-setup-guide' }, { slug: 'using/linear-setup-guide' }, + { slug: 'using/linear-pak-migration-runbook' }, { slug: 'using/task-lifecycle' }, { slug: 'using/what-the-agent-does' }, { slug: 'using/tips-for-being-a-good-citizen' }, diff --git a/docs/guides/LINEAR_PAK_MIGRATION_RUNBOOK.md b/docs/guides/LINEAR_PAK_MIGRATION_RUNBOOK.md new file mode 100644 index 00000000..a9109aad --- /dev/null +++ b/docs/guides/LINEAR_PAK_MIGRATION_RUNBOOK.md @@ -0,0 +1,62 @@ +# Linear PAK → OAuth migration runbook (Phase 2.0a → 2.0b) + +> **Who needs this.** Operators who deployed Phase 2.0a (single Linear personal API key shared across all teammates) and need to upgrade to 2.0b (per-workspace OAuth). If you're starting fresh on 2.0b, read [LINEAR_SETUP_GUIDE.md](./LINEAR_SETUP_GUIDE.md) instead. + +2.0b is a **hard cutover** — no `--use-pak` fallback. Plan for a short maintenance window (~30 min for a single workspace). + +## What changes under the hood + +| 2.0a | 2.0b | +|---|---| +| Single `LinearApiTokenSecret` (one PAK shared by all teammates) | Per-workspace `bgagent-linear-oauth-` containing `{access_token, refresh_token, expires_at, client_id, client_secret, …}` | +| Agent runtime granted `secretsmanager:GetSecretValue` on one ARN | Same action but on the `bgagent-linear-oauth-*` prefix | +| `LinearApiTokenSecret` CFN resource | Removed entirely — no automated rollback once 2.0b is deployed | + +## Pre-deploy checklist + +Run these BEFORE deploying 2.0b so the maintenance window is short: + +1. **List in-flight tasks**: `bgagent list --status RUNNING --status PENDING`. The migration won't corrupt them, but their final Linear comment may fail because the OAuth token isn't authorized at agent-run time. +2. **Pick one workspace to migrate first** (lowest-traffic if multi-workspace). +3. **Note the workspace's `urlKey`** — the `` in `linear.app//...`. You need it for `bgagent linear setup `. +4. **Confirm CLI admin access**: AWS principal needs `secretsmanager:CreateSecret` on `bgagent-linear-oauth-*` AND `dynamodb:PutItem` on `LinearWorkspaceRegistryTable`. Without these, `setup` aborts mid-way (OAuth dance succeeds, secret write fails — Linear OAuth app gets stuck with no usable token). + +## Migration steps + +1. **Drain the queue.** Wait for in-flight tasks to finish. Tasks running at deploy time will fail their final Linear comment because the token resolver short-circuits when neither `LinearApiTokenSecret` (gone) nor `bgagent-linear-oauth-` (not yet created) is present. + +2. **Deploy 2.0b**: `mise //cdk:deploy`. Adds `LinearWorkspaceRegistryTable`, removes `LinearApiTokenSecret` + IAM grants, adds the `bgagent-linear-oauth-*` prefix grant on the agent runtime, webhook processor, and orchestrator. + +3. **For each Linear workspace**, follow the [setup walkthrough](./LINEAR_SETUP_GUIDE.md#setup-walkthrough) starting at step 2. Each workspace needs: + - A new Linear OAuth app (scopes: `read,write,app:assignable,app:mentionable`) + - `bgagent linear setup ` to run the OAuth dance and write the per-workspace secret + - Webhook signing secret pasted into ABCA via `update-webhook-secret` + +4. **Re-onboard projects.** `LinearProjectMappingTable` rows survive the migration (keyed on `linear_project_id` UUID, stable). Verify with `bgagent linear list-projects` that the listed projects still match what's mapped. + +5. **Verify with a test issue.** Apply the trigger label in each onboarded workspace and confirm the agent posts as `bgagent[bot]` (not as the previous PAK owner's Linear identity). The author byline change is the cleanest signal that OAuth is on the wire. + +6. **Decommission the PAK.** Once 2.0b is verified, revoke the personal API key in [Linear → Settings → Security → Personal API keys](https://linear.app/settings/account/security). Clean break, no rollback. + +## Rollback + +If 2.0b fails verification before you've done the OAuth setup: + +- The `LinearApiTokenSecret` CFN resource has been deleted, so `cdk deploy` of the previous commit recreates it but **with an empty secret value**. You'd have to re-paste the PAK manually. +- Recommended: **fix-forward**. The 2.0b OAuth dance is a 5-minute step per workspace; rolling back is rarely worth the time. + +## What survives the migration + +- `LinearUserMappingTable` — keyed on `(organization, user UUID)`, unchanged across PAK→OAuth +- `LinearProjectMappingTable` — keyed on `linear_project_id` UUID, also stable +- `LinearWebhookDedupTable` — TTL-bounded; rows from the maintenance window TTL out within 8h +- GitHub PR comments and Linear-issue mappings on in-flight task records + +## What does NOT survive + +- `LinearApiTokenSecret` Secrets Manager value — gone with the CDK resource +- The 2.0a `linear-api-key` AgentCore credential provider, if 2.0a-with-Identity was deployed mid-Phase. Clean it up after with: + ```bash + aws bedrock-agentcore-control delete-api-key-credential-provider --name linear-api-key + ``` + Phase 2.0b-O2 doesn't use AgentCore Identity at all, so there's nothing to clean up if you skipped the parked 2.0a-Identity branch. diff --git a/docs/guides/LINEAR_SETUP_GUIDE.md b/docs/guides/LINEAR_SETUP_GUIDE.md index 408ba2e2..29389c46 100644 --- a/docs/guides/LINEAR_SETUP_GUIDE.md +++ b/docs/guides/LINEAR_SETUP_GUIDE.md @@ -1,370 +1,190 @@ # Linear integration setup guide -This guide walks through setting up the ABCA Linear integration. Once configured, applying the `bgagent` label to an issue in a mapped Linear project triggers an autonomous task. The agent posts progress comments back on the Linear issue as it works. - -> **Phase 2.0b** — ABCA now authenticates to Linear via OAuth (`actor=app`) instead of a personal API key. One per-workspace OAuth secret in AWS Secrets Manager, one OAuth app (or one per workspace, your choice). Personal API keys are no longer supported (see [Migration from 2.0a (PAK) to 2.0b (OAuth)](#migration-from-20a-pak-to-20b-oauth) below). +Set up the ABCA Linear integration so that applying a label to a Linear issue triggers an autonomous task. The agent posts progress comments back on the issue as it works. ## Prerequisites - ABCA CDK stack deployed (see [Developer guide](./DEVELOPER_GUIDE.md)) - A Cognito user account configured (see [User guide](./USER_GUIDE.md)) -- A Linear workspace where you have **admin** access (you'll create an OAuth app and install it on the workspace) -- AWS CLI configured with credentials for your ABCA account, with `bedrock-agentcore-control:*` permissions on the deployment region +- A Linear workspace where you have **admin** access - The `bgagent` CLI installed and logged in (`bgagent configure` + `bgagent login`) ## How it works -1. A Linear-workspace admin creates a Linear OAuth app and authorizes it on the workspace via `bgagent linear setup`. The workspace's OAuth token (access + refresh) is stored in a per-workspace Secrets Manager secret named `bgagent-linear-oauth-`. **One install per workspace, used by all teammates** — this matches the v1 personal-API-key semantics. -2. A user adds the `bgagent` label (configurable per project) to a Linear issue. -3. Linear fires a webhook to `POST /v1/linear/webhook`. ABCA verifies the HMAC signature and dedups retries. -4. A processor Lambda looks up the Linear `organizationId` in `LinearWorkspaceRegistryTable` to find that workspace's OAuth secret ARN, reads the secret, refreshes the access token if expiring, then resolves the project → repo mapping and creates a task with `channel_source: 'linear'`. -5. The agent clones the repo, writes `.mcp.json` with Linear's hosted MCP server using the freshly-resolved access token, and runs. It uses `mcp__linear-server__save_comment` / `mcp__linear-server__update_issue` to post updates as `bgagent[bot]` (the OAuth app's identity). -6. The agent opens a PR on GitHub and adds a final comment to the Linear issue with the PR link. +A Linear-workspace admin creates a Linear OAuth app and authorizes it on the workspace. The OAuth token is stored in a per-workspace Secrets Manager secret (`bgagent-linear-oauth-`). When a user adds the trigger label to a Linear issue, Linear fires a webhook to ABCA; the receiver verifies the HMAC, looks up the workspace, refreshes the access token if needed, and creates a task. The agent clones the repo, opens a PR, and comments on the Linear issue as `bgagent[bot]`. -**Trigger**: only Linear issues with the configured label in a mapped project create tasks. Issues without the label, or in unmapped projects, are ignored. Label removal does not cancel a running task. +**Multi-workspace**: a single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own per-workspace OAuth secret + signing secret. Webhook subscriptions are workspace-scoped (Linear generates a fresh signing secret per subscription), so each workspace must configure its own webhook in Linear. -**Multi-workspace**: a single ABCA deployment can serve multiple Linear workspaces. Each workspace gets its own per-workspace OAuth secret via `bgagent linear add-workspace`. See [Adding additional Linear workspaces](#adding-additional-linear-workspaces) for details, including the per-workspace OAuth-app option needed when Linear apps are kept private. +## Setup walkthrough -> **Phase 2.0a (parked).** The previous design routed OAuth through AgentCore Identity credential providers. That path is parked — Phase 2.0b-O2 (shipped) reads Secrets Manager directly because AgentCore Identity's `USER_FEDERATION` flow has an open service-side bug. The setup steps below describe the shipped flow only. +This walkthrough covers both the first install and adding additional workspaces. The branching is small — call out at each step which commands run for which case. -## Step-by-step setup +### 1. Decide the workspace `` -### Step 1: Create the Linear OAuth app +The slug is the URL key from `https://linear.app//...`. Find it in Linear → Settings → Workspace → URL key, or look at any URL while logged into the workspace. -Run: +### 2. Create a Linear OAuth app ```bash bgagent linear app-template ``` -The command prints the exact field values to paste. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) (make sure you're signed into the right workspace — use Linear's workspace switcher in the sidebar) and fill in the fields exactly as the template lists. The template marks which fields are required for the `actor=app` agent flow; missing them produces a cryptic "Invalid redirect_uri" error. - -Click **Save**, then copy the **Client ID** and **Client Secret** from the app's detail page. - -### Step 2: Authorize via OAuth - -```bash -bgagent linear setup -``` - -Where `` is the Linear `urlKey` of the workspace (e.g. `acme` from `https://linear.app/acme/...`). - -The wizard prompts for the **Client ID** and **Client Secret** from Step 1, opens your browser to Linear's consent screen, captures the redirect, and stores the OAuth token bundle in Secrets Manager. **Make sure your browser is signed into the right workspace** before authorizing — that's where the app gets installed. - -When the OAuth dance finishes, the wizard pauses at a `Webhook signing secret:` prompt. Move on to Step 3 in a second terminal. - -### Step 3: Configure the Linear webhook +The command prints exact field values to paste. Open [Linear Settings → API → New application](https://linear.app/settings/api/applications/new) (signed into the right workspace — use Linear's sidebar workspace switcher if needed) and fill in the fields exactly as the template lists. -While `bgagent linear setup` is paused at the `Webhook signing secret:` prompt, open a second terminal and run: +The template marks which fields are required for the `actor=app` agent flow; missing them produces a cryptic "Invalid redirect_uri" error. -```bash -bgagent linear webhook-info -``` - -It prints the webhook URL for your stack and the values to paste into Linear. Open [Linear Settings → API](https://linear.app/settings/api) → **Webhooks** → **+** and follow the printed instructions. Then open the webhook's detail page, copy the **signing secret** (starts with `lin_wh_`), and paste it back into the first terminal where `setup` is waiting. +Click **Save**, then copy the **Client ID** and **Client Secret** from the app's detail page. -> **Rotating the signing secret later** — use [`bgagent linear update-webhook-secret `](#webhook-signature-verification-fails-repeatedly), not `setup`. It updates just the secret without re-running OAuth. +> **Adding a second workspace?** You only need a new OAuth app if you want per-workspace isolation. Otherwise, edit your existing app and toggle **Public: ON** so it can be authorized from any workspace. Trade-off: shared apps revoke together; per-workspace apps don't. -### Step 4: Onboard a Linear project +### 3. Authorize the app on the workspace -Map a Linear project UUID to the GitHub repo you want tasks routed to: +For your first workspace: ```bash -bgagent linear onboard-project --repo owner/repo +bgagent linear setup ``` -Optional flags: - -| Flag | Purpose | Default | -|------|---------|---------| -| `--label