feat(linear): multi-workspace support — add-workspace, per-workspace webhook signing, update-webhook-secret#200
Draft
isadeks wants to merge 21 commits into
Draft
Conversation
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.
…space OAuth-app option 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).
…--client-secret flags 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.
The interactive prompt previously printed 'found <slug>' 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.
… 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.
The command still pulled from the parked PAK secret
(`LinearApiTokenSecretArn`), which we removed in Phase 2.0b. Symptom:
`Could not find LinearApiTokenSecretArn in stack outputs.`
Rewrite to scan Secrets Manager for `bgagent-linear-oauth-*` secrets
and query each workspace's projects with its own OAuth token. Supports
`--slug <slug>` to scope to one workspace; without it, queries every
installed workspace and labels each project with its source.
Also: switch to the `Bearer <token>` auth header and the
`teams(first: 1) { nodes { name } }` shape (the old `team` field on
Project no longer exists in Linear's GraphQL).
Adds a `LINEAR_OAUTH_SECRET_PREFIX` const in `linear-oauth.ts` to
keep the secret-name contract in one place.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
'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).
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".
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.
…hook-secret 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
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.
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.
…runbook 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).
Default 128 MB OOMs at module init since the attachment-screening path bundles pdf-parse + URL-resolver libs alongside the existing AWS SDK + bedrock-agentcore deps. Symptom: every webhook from a correctly-configured workspace returns 200 from the receiver (which async-invokes the processor and returns immediately) but the processor crashes with Runtime.OutOfMemory before reaching the task- creation path. No task gets created; Linear stops retrying after the receiver's 200 ack. Silent failure mode. Caught while smoke-testing per-workspace webhook signing on a multi-workspace install: signatures verify, receiver dispatches, processor OOMs at memorySize: 128. Bumping to 512 MB gives ~4× headroom and lifts CPU enough that p99 cold-start stays under the 30s API Gateway deadline.
The auto-link in setup / add-workspace mapped the wrong UUID. With actor=app, Linear's `viewer` query returns the OAuth bot user (a synthetic `<uuid>@oauthapp.linear.app` identity), not the human admin who ran the wizard. The bot never applies labels, so the auto-link silently mapped a UUID that the processor would never see in practice — every human-triggered task got dropped with "Linear actor has no linked platform user." This is the bug @Sphias hit installing demo-abca: the OAuth dance linked bot user `b15f33ef…` to her Cognito sub, but her human Linear identity in demo-abca is `91999ba0…` and was never mapped. Fix: - Drop the auto-link write from `setup` and `add-workspace`. The registry row + OAuth secret are still written; only the user- mapping row is no longer auto-stamped. - New `bgagent linear link-user <slug>` command. Prompts (or accepts via flags) the Linear user UUID + Cognito sub, validates the workspace is in the registry, writes the mapping row with link_method='manual_cli'. Defaults Cognito sub to the caller — one-flag invocation for the admin's own row, two-flag for each teammate. - End-of-flow output for setup / add-workspace updated: instructs the operator to run `link-user` BEFORE applying labels (otherwise every triggered task is dropped). Doc trade-off considered: Auto-discovery via Linear's `users(filter: { email })` was rejected because the same human can have a different email in Linear vs. Cognito (synthetic `@oauthapp.linear.app` for actor=app, separate corporate emails for humans). Manual UUID is more friction but always correct. Pre-existing rows in deployed installs (with the wrong bot UUID) are left untouched. They're harmless — they just don't match any actor the processor sees. Operators can clean them up by querying the table; documented as v1.x followup. Tests: 37/37 linear suite pass. The `link-user` command itself is straightforward DDB plumbing; the test gap is acceptable given the end-to-end verification path (run, see warning, run link-user, see task created) is what proves the contract.
…user for teammates The previous link-user command (just landed) put the teammate-onboarding UX on the admin's hot path: every fresh install needed a manual second command to map the admin's own Linear identity. For the common single- admin case that's friction with no benefit. Restructure: - runSelfLinkPicker() helper — shared logic that lists Linear workspace members, prompts the caller to pick themselves, writes the mapping row using the caller's Cognito sub. - setup and add-workspace call it inline at the end of their flow, right after the OAuth dance completes (token + workspace_id are already in scope). One command, no separate step. If the picker fails (network, no humans), setup completes anyway and prints a clear "your tasks won't dispatch until you self-link" hint. - New `bgagent linear invite-user <slug>` for the teammate case. Admin picks the teammate from the same member list, CLI writes a pending#<code> row, prints a one-time command to hand off (Slack/email/etc). - The existing `bgagent linear link <code>` command (which was 80% built but never wired to a code-generator) becomes the teammate's redemption command. Now does a dry-run preview first so the teammate sees the Linear identity name+email and confirms BEFORE the write hits DDB. The /v1/linear/link API handler grew a dry_run flag for this preview. - Old standalone link-user command removed. Self-link is in the setup wizard; teammate-link is invite-user + link. Trust model unchanged from the prior plan: - Admin's self-link trusts the admin (their CLI session has Cognito + DDB write IAM anyway — no new attack surface). - Teammate's link separates the halves: admin asserts the Linear identity (picker), teammate confirms with their own Cognito session and can abort if the admin picked the wrong member. No PAKs change hands. Doc: walkthrough drops the "now run link-user" step (folded into setup). New "Inviting teammates" section explains invite-user + link with the trust-model rationale.
PR aws-samples#200's invite-user generates lowercase codes (link-3f8b4a2c shape). The handler dropped its .toUpperCase() to round-trip them correctly. Two pre-existing tests still asserted pending#ABC123 and expected normalization to uppercase — updated to use the new code shape and assert "preserves case + trims whitespace" instead.
CI's Fail build on mutation step caught ESLint reordering linear-oauth-resolver before linear-verify.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Three CLI commands + a webhook architecture fix that together make ABCA's Linear integration usable against multiple Linear workspaces from one stack.
bgagent linear add-workspace <slug>— install OAuth in additional workspaces without re-running the full setup wizardbgagent linear update-webhook-secret <slug>— rotate or fix a workspace's webhook signing secret without re-running OAuthorganizationId(workspace-scoped Linear webhooks were silently dropping signatures from any workspace beyond the first)Why
Single-workspace deploys work great after #160. The moment you try to onboard a second workspace, two things break:
setupis heavy (full OAuth dance, registry write, user mapping, webhook prompt).add-workspaceskips the parts you've already done.Both surfaced concretely while onboarding a second Linear workspace this week —
add-workspacegot anaccess_deniedfrom Linear when the OAuth app was private to the origin workspace; webhook events 401'd silently when the new workspace was finally authorized.What's in here (4 logical pieces)
1.
bgagent linear add-workspace <slug>Lighter-weight install for additional workspaces. Reuses an existing workspace's OAuth Client ID as a default (Enter to keep it, or paste a new one for a per-workspace OAuth app). Always interactive — secrets never on the command line. Refuses to silently overwrite an already-onboarded workspace's registry row.
2. Per-workspace webhook signing secret
StoredOauthToken/StoredLinearOauthTokenget an optionalwebhook_signing_secretfield. Webhook receiver:Cross-language schema parity test extended to track required vs optional fields so the validator const can't drift from the interface.
3. `bgagent linear update-webhook-secret `
Rotation / recovery primitive. The OAuth dance can't re-run when the app is already installed in a workspace — Linear returns `access_denied`. This command sidesteps it: read the existing OAuth bundle, prompt for the new signing secret, re-upsert the bundle. No DDB writes, no Linear API calls, no stack-wide writes.
This made `setup --rotate-webhook-secret` redundant — the flag is removed in this PR.
4. `list-projects` fixes + setup-guide rewrite
Backward compatibility
Designed for zero impact on existing single-workspace deploys:
Trust model
The `organizationId` in the webhook body is attacker-controlled. But it only selects which secret to verify against — they still need the matching signing secret to forge a valid signature. The no-fallback-on-per-workspace-mismatch rule prevents an attacker from bypassing a per-workspace secret by tricking the receiver into re-checking against the stack-wide one.
The `linear-webhook-multi-workspace.test.ts` suite covers this:
Tests
CDK: 1789 / 1789. CLI: 286 / 286 (after rebuild).
Smoke test
Deployed to a personal AWS account against two Linear workspaces with separate OAuth apps. Verified:
Test plan