Skip to content

feat(linear): mint OAuth tokens via AgentCore Identity Token Vault, and latch revoked credentials (#809, #812) - #831

Merged
isadeks merged 86 commits into
mainfrom
feat/809-linear-identity-vault-pr
Sep 4, 2026
Merged

feat(linear): mint OAuth tokens via AgentCore Identity Token Vault, and latch revoked credentials (#809, #812)#831
isadeks merged 86 commits into
mainfrom
feat/809-linear-identity-vault-pr

Conversation

@isadeks

@isadeks isadeks commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #809
Closes #812

Mints Linear OAuth tokens through the AgentCore Identity Token Vault instead of storing a long-lived credential, and detects/latches/announces a revoked channel credential instead of surfacing it as a failed user task.

Both issues ship together because the code is interdependent: #812's typed VaultTokenResult is what the vault resolver returns, and #809's recorded vault subject flows through #812's revocation recorder. Splitting them would mean reordering commits that touch the same files for a boundary a reviewer does not benefit from.

Flag-gated on --context enableLinearIdentityVault=true (default off). With the flag off the synthesized template contains none of it — asserted by test.

What changes for an operator

bgagent linear setup <slug> is now the only Linear onboarding command, and it needs one consent.

bgagent linear setup <slug>
  • Uses the vault when the deployment has one, Secrets Manager otherwise, announced in one line rather than requiring a flag to be known up front
  • Consent runs through a hosted page openable in any browser, so onboarding works from a cloud desktop, SSH session or container — the previous localhost loopback could not
  • vault-setup, --hosted and --localhost are gone; there is nothing left to choose
  • app-template resolves the URLs it used to demand: the consent page from a stack output, the vault callback from the provider, and the webhook endpoint from the configured API URL

The design decision worth reviewing

The vault subject is derived from the workspace slug, not its organization UUID, and recorded on the registry row as vault_user_id.

The UUID is only knowable after a token exists. Deriving the subject from it therefore forces two consents — one to learn the organization, a second to bind the vault. A slug-derived subject is known before consent, which is what makes single-consent onboarding possible. Both resolvers prefer the recorded value and fall back to the derived form for workspaces onboarded before it was stored, so existing vault installs keep minting under the subject their grant is actually under.

Two deliberate divergences from ADR-016 P1, both noted in the ADR:

  1. Per-workspace credential provider (bgagent-linear-oauth-<slug>) rather than one shared provider with per-workspace subjects. Keeps each workspace's grant and rotation isolated. Cost: each workspace adds one redirect URI to the Linear app.
  2. The agent self-mints via boto3 on both substrates rather than receiving an injected workload token.

Known limitations, stated plainly

  • ECS substrate is live-unverified. The env and task-role grant are wired and unit-tested, but no ECS task definition existed on the test deployment. The agent's mint path is substrate-uniform by design.
  • +9 CloudFormation resources with the flag on — 450 → 459 of the hard 500 limit, re-measured on this branch after fix(cdk): reclaim resource and byte headroom under both CFN ceilings (#852) #854 reclaimed 30. The consent page is in a nested stack so it costs the root one resource.
  • The vault and the MicroVM substrate are still refused together, and that guard is now obsolete. It was written when the pair synthesized 505 against a hard 500. After fix(cdk): reclaim resource and byte headroom under both CFN ceilings (#852) #854 the same pair measures 476 (483 with the tool gateway), so the combination fits and the fail-fast in cdk/src/stacks/agent.ts now refuses a deployable configuration while quoting a figure that is no longer true. The vault was always wired for MicroVM — platform config carries the workload name, the guest execution role holds the mint grant. Note the covering test asserts the throw, so it pins the guard rather than detecting the headroom; it did not and could not go red when the room appeared. Removing the guard, and adding enableLinearIdentityVault to the resource-budget matrix fix(cdk): reclaim resource and byte headroom under both CFN ceilings (#852) #854 introduced so the combination is genuinely guarded rather than merely permitted, is the follow-up.
  • The consent page URL is a generated CloudFront domain — harmless on the vault path. There it is the resourceOauth2ReturnUrl, registered on the AgentCore workload identity's allowlist, which the provisioning custom resource rewrites on every deploy; the CLI reads the current value from the LinearVaultConsentUrl output rather than caching one. So if the domain changes, nothing breaks. What Linear holds on that path is AgentCore's own callback (…/identities/oauth2/callback/<uuid>), which survives re-runs because a duplicate provider name updates in place instead of minting a new id. The instability matters only on the Secrets-Manager (non-vault) leg, where the page really is Linear's redirect_uri and a changed domain means adding the new URI to the app — both variants of that leg print the warning, and Linear accepts several URIs so the stale entry is harmless. A stable custom domain, or serving the page from the existing API Gateway, would retire even that.
  • Secrets Manager is retained on every path. Retiring it is ADR-016 P2.

Live verification

End-to-end on a real Linear workspace, with the agent-authored PR as the artifact.

Scope caveat, stated because it changes what this proves: the workspace was a FRESH install, onboarded by the merged setup command in one pass. So the vault mint, the consent flow and the revocation latch are verified — but the path a workspace takes when its Secrets Manager bundle holds no grant and was not written by this flow is covered by unit tests only. Re-verification on a genuinely fresh vault workspace is what takes this out of draft.

  • One consent, no localhost, from a cloud desktop
  • Vault-minted token verified against Linear as an actor=app agent token (…@oauthapp.linear.app), not a user token
  • A labelled issue produced: 👀 reaction, Backlog → In Progress, task created, PR-opened comment, preview screenshot — with the Linear token resolved via the vault on every hop and no Secrets Manager fallback
  • Revocation path proven in isolation: row latched status=revoked, one real SNS message, and zero duplicates on repeat events

Defects the live run caught that unit tests could not

Recorded because each is a class, not a one-off:

  1. DynamoDB Limit: 1 on a filtered Scan reported an onboarded workspace as absent — Limit caps items read before the filter.
  2. AgentCore returns ValidationException, not ConflictException, for a duplicate name. Broke re-runs, and would have broken the next deploy when the custom resource re-issued Create.
  3. IAM: GetWorkloadAccessTokenForUserId authorizes against the workload-identity directory, not the named identity; GetResourceOauth2Token against the token vault. Unit tests asserted the ARNs I had chosen, so only a live call could surface it.
  4. customParameters are part of the vault's cache key. Resolving without the consent-time set reports "needs consent" against a live grant, silently degrading every caller to Secrets Manager.
  5. Linear rejects a dead refresh token with invalid_request, not the RFC's invalid_grant, with the detail in error_description. The revocation branch had never executed.
  6. A row parser that copies fields explicitly dropped the vault subject. Every caller threaded it correctly; the parser discarded it. The test seam took three unpacked arguments and could not express the field, so no test at that seam could catch it — the seam now takes the full request.
  7. The vault grant was wired to one Lambda. Five others resolve Linear tokens; each fell back to a Secrets Manager token a vault workspace does not maintain, so a task succeeded while its issue showed nothing after the opening comment. The first guard for this pinned a hardcoded count of 4, derived by grepping handlers that import a Linear module directly — which omitted the two that reach a minting resolver two hops away, and whose count-based assertion passed for the very mistake it named. Replaced by a pinned set plus a check computed from the handler source graph.
  8. Not from the live run — from rendering the operator alert instead of reading it. The revocation email printed the same bgagent linear setup command twice on the vault path, and a .filter(line => line !== '') intended for one conditional entry stripped every blank-line separator, collapsing the message into a single block. Both were green under toContain, which cannot see a duplicate or a missing paragraph break; the assertions now pin the command count and the separators, and were mutation-checked against the old rendering.

Test plan

  • mise run build green — CDK 4411 / 210 suites, CLI 867 / 59 suites, agent 1762, docs build + link check, synth
  • security:secrets, security:deps, security:retire, security:sast clean
  • Flag-off synth asserted to contain no workload identity, no token grant, no agent env, no consent page
  • Every new guard mutation-tested — the fallback classifier, the recorded-subject preference on both resolvers, the fail-closed token preservation, the inherited-secret detection, the announce/latch ordering, the revocation self-heal, the vault-aware health check, and the registry-write and directory-ARN grants each fail when the fix is reverted
  • Live-verified end to end on a real workspace, both the vault mint and the revocation latch — see the scope caveat above
  • The narrowed IAM grants are now live-verified, and the first attempt was wrong. UpdateWorkloadIdentity authorizes against the bare workload-identity-directory/default, so it cannot be scoped to one identity; these actions do a multi-resource check and IAM names only the first resource it fails, so three successive deploys each surfaced a different AccessDenied. Final grant is all four lifecycle actions on …-directory/default plus …-directory/default/workload-identity/* — still narrower than the original account-wide workload-identity-directory/*, but the consequence is recorded in the construct: IAM cannot fence off the cross-stack allowlist clobber; the stack-derived NAME is what does. Verified by invoking the provisioning handler with an idempotent Update (previously AccessDenied, now clean), then confirming the return URLs were unchanged and the vault still mints a working actor=app token.
  • Deployable again. Deploying to a dev account had been failing before it reached any of this, for a reason unrelated to this PR: the branch carried PINNED_LOG_DELIVERY_BY_STACK while both dev accounts had moved to library-named log-delivery resources. A DeliverySource is unique per (resource ARN, log type) account-wide and the runtime ARN does not change, so a pinned id creates a second source for the same runtime and CloudFormation rolls the whole stack back with AlreadyExists. That is fix(agent): log-delivery pin table is keyed by stack name but holds per-account state, so it breaks deploys it was meant to protect #703/fix(agent): stop hard-coding log-delivery logical ids #705; dev was deployed from an integration branch carrying its pin removal, and cdk diff confirmed zero log-delivery churn before deploying.

Two pre-existing gate failures are unrelated to this branch: security:sast:masking reports on 49 files including lines that already carry justified nosemgrep comments (findings in files touched here are annotated), and security:gh-actions reports one medium in .github/workflows/auto-approve.yml, untouched here.

isadeks added 30 commits August 27, 2026 15:31
…ault (#809)

Slice 1 of RFC #249 Phase 1. Adds the stack-owned half of the Linear OAuth
token vault: a LinearIdentityVault construct that provisions the AgentCore
workload identity backing 3LO (USER_FEDERATION) token issuance, plus a
grantMintToken() helper for the token data-plane calls.

- New construct linear-identity-vault.ts + bundled custom-resource handler
  (workload-identity create/update/delete; synchronous, so onEvent-only, no
  isComplete poller). Mirrors the registry.ts Provider-framework idiom.
- Registers the return-URL allowlist with BOTH the CLI localhost loopback and
  an optional hosted onboarding URL (spike F9/F11: allowlist enforced, entries
  coexist, caller picks per-call).
- Wired into agent.ts + LinearIntegration behind the context flag
  enableLinearIdentityVault (additive, default-off; default synth byte-for-byte
  unchanged, verified 0 vault occurrences). Webhook processor gets the token
  data-plane grant + LINEAR_VAULT_ENABLED / LINEAR_WORKLOAD_IDENTITY_NAME env
  only when the vault is present.
- Bootstrap least-privilege: map Custom::LinearWorkloadIdentity to
  lambda:InvokeFunction; synth-coverage test asserts the flag-on path is fully
  bootstrap-covered (deploy-safe), matching the tool-gateway pattern.
- Adds @aws-sdk/client-bedrock-agentcore-control (control-plane sibling of the
  already-present client-bedrock-agentcore data-plane client).

Token resolution still reads the per-workspace SM token; wiring the resolver
onto the vault path is the next slice. No behavior change with the flag off.
…lback (#809)

Slice 3a of RFC #249 Phase 1 — the Lambda-side (trusted) token resolver.

- New linear-vault-token.ts: resolveLinearTokenViaVault mints a USER-BOUND
  workload token (GetWorkloadAccessTokenForUserId, user id
  linear-workspace-<orgId> per the registry-table design) then exchanges it
  (GetResourceOauth2Token, USER_FEDERATION). Returns null — never throws — on a
  consent-required response OR any API error, so the caller degrades to the SM
  token rather than blocking a task. Matches the Phase-0 spike (F2/F7).
- resolveLinearOauthToken: when LINEAR_VAULT_ENABLED and the workspace has a
  provider_name (vault-onboarded), try the vault first and fall through to the
  existing Secrets-Manager path on null. Absent provider_name / flag off skips
  the vault entirely, so SM-only installs synth + behave unchanged.
- RegistryRow gains provider_name; parseRegistryRow now maps it.
- Tests: vault module (happy / consent-required→null / no-WAT / error→null) and
  resolver (vault-used-no-SM-read, vault-null→SM-fallback, no-provider_name→skip).

Agent-side reactions token + the CLI setup path that writes provider_name are
the next slices. No behavior change with the flag off.
Slice 3b of RFC #249 Phase 1 — the agent (reactions) token path.

- config.py _resolve_linear_token_via_vault: self-mints through boto3
  (get_workload_access_token_for_user_id → get_resource_oauth2_token,
  USER_FEDERATION) so it behaves identically on the AgentCore and ECS
  substrates — both authorize against the agent session role's IAM rather than
  the AgentCore-injected WorkloadAccessToken header. User id is
  linear-workspace-<orgId> (matches the Lambda resolver + registry-table
  convention). Returns '' — never raises — on consent-required / error, so
  resolve_linear_api_token falls through to the Secrets-Manager token.
- resolve_linear_api_token tries the vault first when LINEAR_VAULT_ENABLED and
  the task carries linear_provider_name + linear_workspace_id; SM-only installs
  and the flag-off path are untouched. Updated the stale 2.0a-parked docstring
  (the spike showed USER_FEDERATION no longer has the bug that parked it).
- Tests: mint-and-cache, consent-required→SM, vault-error→SM, no-provider→skip,
  flag-off→skip. Full agent quality gate green (1588 pass, 82.8% cov).

The CDK wiring that stamps linear_provider_name/linear_workspace_id into
channel_metadata + grants the agent session role is the next step. No behavior
change with the flag off.
…grant (#809)

Slice 3c of RFC #249 Phase 1 — makes the agent-side vault path actually fire.

- ResolvedLinearToken gains providerName; resolveLinearOauthToken returns it on
  both the vault and SM paths (carried through so the agent can attempt the
  vault even when the Lambda resolved via SM).
- linear-webhook-processor stamps linear_provider_name + linear_workspace_id
  into channel_metadata for the single-task path, and persists
  linear_provider_name into the orchestration release_context so released
  sub-issue children inherit it (workspace id already rides the existing
  credentials_ref stamp — no duplicate).
- orchestration-store/release thread linear_provider_name through the persisted
  ReleaseContext + release params.
- agent.ts: resolve enableLinearIdentityVault early; set LINEAR_VAULT_ENABLED +
  LINEAR_WORKLOAD_IDENTITY_NAME on the agent runtime env (both substrates) and
  grant the agent session role the token data-plane calls — only under the flag.
  Workload name is one shared const so env + construct can't drift.
- Tests: agent-stack gate (default synth omits vault + grant + env; flag-on gets
  the grant + env). Touched suites green (webhook-processor, orchestration
  store/release, agent stack). No behavior change with the flag off.
Slice 4 of RFC #249 Phase 1 - the CLI path that makes a workspace
vault-onboardable (writes provider_name, which is what activates the runtime
vault path built in slice 3).

- New cli/src/linear-vault.ts helpers:
  - upsertLinearCredentialProvider: create (idempotent, update on conflict) the
    CustomOauth2 provider from the workspace's Linear app client id/secret;
    returns the fixed vault callback URL to register on the Linear app (spike F6).
  - beginVaultConsent: drive the 3LO USER_FEDERATION round-trip - user-bound
    workload token then get-resource-oauth2-token with customParameters
    actor=app + prompt=consent (spike F1); returns the authorizationUrl plus a
    poll() that yields the minted token once consent completes server-side.
  - Deterministic linearVaultProviderName / linearVaultUserId matching the
    runtime resolvers.
- New 'bgagent linear vault-setup <slug>' subcommand: layers the vault onto an
  already-SM-onboarded workspace (reads client id/secret + workspace id from the
  existing registry row / SM secret), creates the provider, prints the callback
  URL to register, opens the consent browser, polls for the token, then records
  provider_name on the registry row. Localhost return URL (already on the
  workload allowlist); consent completes at the AgentCore callback, polled - no
  localhost listener needed.
- Adds @aws-sdk/client-bedrock-agentcore (data-plane) as a direct cli dep.
- Tests: provider create + idempotent conflict-update, consent
  needed-poll-token, already-consented shortcut, neither-token-nor-url error.
  Full CLI suite green (758). Additive - no existing command changed.
`bgagent linear vault-setup maguireb` reported an already-onboarded workspace as
"not onboarded". The filtered Scan passed `Limit: 1`, but DynamoDB applies
`Limit` to the items READ *before* the FilterExpression runs — so it read one
arbitrary row (demo-abca), filtered it out, and returned nothing. Live-caught on
a two-workspace registry; reproduced with the AWS CLI (Limit 1 => ScannedCount 1,
Count 0; no Limit => Count 1).

- Extract the lookup into an exported `findWorkspaceRowBySlug` (matching the
  file's existing exported-for-test idiom) with no `Limit`, and match the slug
  explicitly rather than trusting Items[0].
- Tests: finds a workspace that is NOT the first row scanned (the broken case),
  asserts no `Limit` is passed, and still returns undefined when genuinely absent.
- Verified against the live dev registry: maguireb + demo-abca both resolve, a
  bogus slug still does not.

NOTE (pre-existing, not changed here): `findReusableOauthAppCredentials` has the
same Limit-with-filter shape, and its test asserts `Limit: 1` — so a registry
whose first scanned row is inactive would wrongly report "no active workspace".
Left alone as out of scope for this issue.
…ready-exists (#809)

AgentCore reports a duplicate name as a ValidationException, not the
ConflictException the name suggests. Verified live against both APIs:
  CreateOauth2CredentialProvider -> "Credential provider with name: … already exists"
  CreateWorkloadIdentity         -> "WorkloadIdentity with name '…' already exists."
Both arrive as ValidationException, so the idempotent create-then-update paths
rethrew instead of updating.

Two real failures fixed:
- `bgagent linear vault-setup` aborted on a re-run ("… already exists") instead of
  updating the provider in place. Live-caught.
- The CDK custom-resource handler would have failed the NEXT deploy: CloudFormation
  sends an Update, the handler re-issues CreateWorkloadIdentity, and the unhandled
  duplicate error rolls the stack back. Caught by deliberately re-creating the
  existing workload identity rather than assuming the guard worked.

Both sites now share the same predicate shape (name === 'ValidationException' &&
/already exists/i), keeping ConflictException accepted in case the service tightens
this later, and still surfacing GENUINE validation errors (bad endpoint, bad return
URL) rather than silently treating them as "exists".

Tests: duplicate-ValidationException updates in place, and a genuine
ValidationException still fails — for both the CLI helper and the CDK handler.
)

Live-caught on dev: with the vault enabled and a provider_name on the workspace,
the webhook processor's vault call was denied —

  not authorized to perform: bedrock-agentcore:GetWorkloadAccessTokenForUserId
  on resource: …:workload-identity-directory/default

grantMintToken scoped both actions to the NAMED workload-identity ARN, but
GetWorkloadAccessTokenForUserId authorizes against the workload-identity
DIRECTORY. The unit test asserted the ARN we chose, not the one the service
requires, so only a live run could surface it.

- grantMintToken now grants the directory ARN (keeping the named identity
  alongside, in case the service tightens later); GetResourceOauth2Token is
  scoped to this account's token-vault oauth2 credential providers (names are
  created at onboarding time, so not known at synth); adds GetSecretValue on
  `bedrock-agentcore-identity!*`, which the vault reads through the caller when
  exchanging (ADR-016 P1 notes the same grant). Still no wildcard resource and no
  control-plane lifecycle actions.
- ECS substrate parity — the same class as the F-2 memory-grant bug. The ECS
  container has its OWN baseEnvironment and task role, so the AgentCore runtime
  env/grant never reached it: the ECS agent would silently stay on Secrets
  Manager even with the vault on. EcsAgentCluster now takes an optional
  linearIdentityVault and wires BOTH the env (LINEAR_VAULT_ENABLED /
  LINEAR_WORKLOAD_IDENTITY_NAME) and the task-role grant, mirroring the
  toolGateway/agentMemory props. The vault construct moved earlier in agent.ts so
  it exists before the ECS cluster; the runtime-role grant stays where `runtime`
  is defined.
- Tests: the construct test now asserts the SERVICE-REQUIRED resources
  (directory ARN, credential-provider ARN, identity secret) and still proves
  least-privilege; new ECS parity test with positive + negative proof, mirroring
  the F-2 guard.

Fail-safe behaviour confirmed live throughout: the denied vault call fell back to
Secrets Manager and the event degraded gracefully rather than crashing.
Second live AccessDenied after the workload-directory fix:

  not authorized to perform: bedrock-agentcore:GetResourceOauth2Token
  on resource: …:token-vault/default

GetResourceOauth2Token authorizes against the token VAULT itself, not only the
oauth2credentialprovider sub-path that was granted. Add the bare
token-vault/default ARN alongside the per-provider path (kept so the grant still
holds if the service moves to per-provider authorization). Test asserts both.
…#809)

With IAM fixed, the runtime vault call succeeded but reported "requires consent"
for a workspace whose grant WAS cached — every resolve silently degraded to the
Secrets-Manager fallback.

AgentCore keys a cached grant by the full token-request shape, customParameters
included. Isolated live against the same user + provider:

  with customParameters {actor: app, prompt: consent} -> accessToken returned
  without them                                       -> authorizationUrl ("needs consent")

So the runtime resolvers must send the IDENTICAL parameter set that
`bgagent linear vault-setup` used at consent time. Both resolvers now do:
- cdk/src/handlers/shared/linear-vault-token.ts: new exported
  LINEAR_VAULT_CUSTOM_PARAMS, passed on GetResourceOauth2Token.
- agent/src/config.py: mirrored _LINEAR_VAULT_CUSTOM_PARAMS (the two are kept in
  sync by comment, like the scopes list).

Tests assert the parameters on both sides, with the reason recorded so a future
"these look redundant on a read" cleanup does not silently reintroduce the
cache miss. Neither the Phase-0 spike nor unit tests could surface this — it only
appears when retrieving an already-consented grant.
The consent flow never completed. `vault-setup` opened the browser and polled
`GetResourceOauth2Token`, but AgentCore's post-consent bounce was unhandled:
nothing listened on the return URL (the browser 404'd) and the federation session
was never finalized, so the poll kept getting an `authorizationUrl` as if consent
had not happened and would have spun until timeout.

The real flow — already documented in this repo's own oauth-callback-server.ts
(lines 103-107, from the parked Phase-2.0a work) — is: Linear consent → AgentCore's
callback (AWS exchanges the code) → AgentCore redirects the browser to
`resourceOauth2ReturnUrl?session_id=…` → the caller captures that session_id and
calls CompleteResourceTokenAuth. This also CORRECTS spike finding F10, which had
called the return URL "cosmetic" — it is load-bearing. The spike reached that
wrong conclusion because it never completed a consent.

- New `finalizeVaultConsent()` wrapping CompleteResourceTokenAuth for the
  per-workspace user id; `beginVaultConsent` now also returns the `sessionUri`.
- vault-setup starts `awaitOauthCallback()` (the existing helper `linear setup`
  already uses) BEFORE opening the browser, requires the `agentcore` callback
  kind, finalizes, then fetches the token. Rejects a `direct-oauth` callback with
  a pointer to `linear setup`, and the timeout message no longer blames consent.
- Idempotent re-runs: probe the CACHED grant before forcing a fresh
  authorization, so re-running on a healthy workspace is a no-browser no-op
  instead of dragging the operator through consent to re-record a provider name.
- Tests: sessionUri is exposed, finalization targets the right user + session,
  a finalization failure propagates rather than leaving the caller polling, and
  the cached re-run makes no forced round trip.

Verified end-to-end against the live maguireb workspace: `bgagent linear
vault-setup maguireb` now completes with no browser and records provider_name.
The CompleteResourceTokenAuth call itself was live-proven earlier (it is what
minted the token that made the runtime vault path work). Full CLI suite green (766).
…809)

Slice 2 of RFC #249 Phase 1. The consent flow previously required a listener on
the CLI's own localhost, which only works when the browser and the CLI share a
machine — on a cloud desktop, SSH box, or container the redirect dead-ends and the
workspace cannot be onboarded at all.

- New LinearVaultConsentPage: private S3 bucket + CloudFront with Origin Access
  Control (same shape as ScreenshotBucket), serving one self-contained static page
  that reads `session_id` from the query string and shows it for the operator to
  paste back. Wired behind the existing `enableLinearIdentityVault` gate; its URL
  is registered on the workload identity's return-URL allowlist alongside the
  localhost loopback (both coexist — the CLI picks per call) and published as the
  `LinearVaultConsentUrl` output.
- CLI: `--hosted` selects that URL (failing closed with a clear message if the
  stack does not publish it), and `--session <sessionUri>` finishes a consent that
  already happened in the browser — no listener at all.

WHY THE PAGE IS STATIC. Finalizing needs CompleteResourceTokenAuth. Doing that on
the page would mean a public, unauthenticated endpoint that completes OAuth
sessions for whoever calls it. Instead the page only DISPLAYS the id and the CLI
finalizes with the operator's own AWS credentials, so the privileged step stays
with a principal we can authorize. (This supersedes an earlier note in the spike
doc that called the return URL "cosmetic" — it is load-bearing, but display-only.)

- The session id is untrusted query input reflected onto the page, so it is
  assigned via `textContent` only. A test asserts no markup-writing sink
  (innerHTML/outerHTML/document.write/insertAdjacentHTML) and no eval appears
  anywhere in the rendered page, that the page is fully self-contained (no
  external script/style/font/URL), and that it degrades to a clear message when
  opened without a session_id.
- Bootstrap least-privilege: map Custom::CDKBucketDeployment, and the
  synth-coverage guard now asserts EVERY CFN type the gated path adds is mapped
  and covered (not just the vault's own), so a future gated resource cannot slip
  in unmapped.
- Stack gate tests extended: default synth still omits the page + output; flag-on
  publishes LinearVaultConsentUrl and ships the deployment.
…arned gotchas (#809)

Marks P1 built + live-verified (flag-gated, SM fallback retained) and adds a
Phase-1 implementation-notes section. The notes are properties of AgentCore
Identity rather than of Linear, so they apply to the Jira/Slack/GitHub
ChannelCredentials in P7 — and none of them are reachable from a unit test:

- The data plane authorizes against surprising resources: the workload-identity
  DIRECTORY (not the named identity) and the token VAULT (not the credential
  provider sub-path). Both surfaced only as a live AccessDenied, because a unit
  test can only assert the ARN you chose. Confirms the GetSecretValue on
  bedrock-agentcore-identity!* that this ADR already anticipated.
- A cached grant is keyed by the whole token request INCLUDING customParameters,
  so resolving without the set used at consent time reports 'consent required'
  despite a valid grant and silently degrades to the fallback.
- Consent must be finalized with CompleteResourceTokenAuth using the session_id
  from the return-URL bounce; the return URL is load-bearing, not cosmetic.
- Revocation changes shape on the vault path (authorization URL, not
  invalid_grant), which is why detection needs #812.
- Two deliberate deviations from the P1 text: a provider per workspace (grant
  isolation) and the agent self-minting on both substrates.
- What Phase 1 does NOT deliver: per-user token attribution (workspace bot
  identity; P6 still open), and it does not stop a vendor revoking a grant.

Starlight mirror regenerated; docs build + link check green.
Synth failed with cdk-nag ERRORs (IAM4 managed policy, IAM5 wildcard S3 grants,
L1 runtime) on CDK's BucketDeployment handler. The suppressions were attached to
this construct, but that handler is a SINGLETON created at STACK scope, so
suppressing on `this` never reached it.

Resolve the singleton by id prefix (`Custom::CDKBucketDeployment*`) rather than
hardcoding CDK's version-dependent hash, which would silently stop matching on a
CDK bump and re-break synth with an error that points at framework code.

Verified live on backgroundagent-dev: synth passes nag, the page serves 200 over
CloudFront, the origin bucket is private (direct S3 GET 403, BPA all-true), the
workload identity allowlist carries BOTH return URLs, and the CLI resolves
LinearVaultConsentUrl for --hosted.
)

The page's delivery machinery (bucket, policy, auto-delete custom resource,
distribution, OAC, and CDK's BucketDeployment singleton with its AWS-CLI layer,
role and policy) is ~10 resources. Carried in the root stack that took
backgroundagent-dev from 486 to 496 against CloudFormation's hard 500-per-stack
limit — spending most of the remaining headroom on a landing page.

Nesting gives those resources their own budget and costs the parent a single
AWS::CloudFormation::Stack, the same trade AgentRegistryStack already makes for
the registry. Root synth is back to 487; the nested template holds 13.

- Bootstrap coverage follows the resources: the synth-coverage guard now walks the
  nested stack too, since CFN creates nested resources with the same execution
  role and an unmapped one fails deploy exactly like a root one.
- Gate test asserts the nesting (root must NOT carry Custom::CDKBucketDeployment)
  so a future inline regression is caught rather than silently eating headroom.

Verified live on backgroundagent-dev: the migration completed, the page serves 200
from the new distribution, and the workload identity's return-URL allowlist
reconciled to the new CloudFront URL by itself — which also confirms the
duplicate-name Update path added earlier works in production.
…809)

The existing assertions prove a dangerous sink is ABSENT. They cannot prove the
page WORKS: a typo in an element id or in the query-parameter name would sail past
every grep and only surface in a browser, on the one flow an operator runs once.

Run the page's inline script under vm against a minimal DOM shim, and assert
behaviour:
- session_id is extracted and rendered, along with the command to run
- a HOSTILE id ('"><img src=x onerror=alert(1)>') survives verbatim as text and
  touches NO markup sink — the shim records any innerHTML/outerHTML write, so this
  demonstrates the XSS property instead of implying it
- with no session_id the page explains itself and removes the empty slots

Also live-checked against the deployed distribution: the page serves 200 with a
session_id query string attached and the bytes are identical to the no-query
response, which is correct — the id is read client-side from window.location, so
the HTML never varies and the cache key legitimately excludes the query string.
The previous commit shipped with eslint failing (@typescript-eslint/no-require-imports
on `require('vm')`). CI's mutation guard would have rejected it — I committed
without re-reading the lint result. Import vm at the top of the file instead.
…flow (#809)

`bgagent linear app-template` printed only the localhost callback, so an operator
following it and then running `vault-setup` hit Linear's 'Invalid redirect_uri
parameter for the application' with nothing in the output pointing at the cause.
Live-caught during onboarding.

The vault flow needs a SECOND callback on the Linear app — the provider callback,
whose id is minted when the credential provider is created, so it cannot be printed
before that exists. The template now says exactly that where the URLs are listed,
and the gotcha list names the misleading error it produces. `--vault-callback-url`
renders a complete template once the provider exists (additive: localhost stays, or
`bgagent linear setup` breaks).

Note the three URLs are sourced differently: the localhost callback is a CLI
constant, the hosted consent page URL IS a stack output (LinearVaultConsentUrl,
read by --hosted), and the provider callback is neither — it is runtime output from
provider creation, which is why no template can carry it.
…iling tasks (#812)

A revoked Linear grant was detected perfectly and then acted on not at all: the
operator found out because a user's task silently did nothing, and every later
event re-detected the same dead grant with no dedup.

`markWorkspaceRevoked` already existed and was already careful (conditioned on
`installed_at`, so a verdict can never revoke the SUCCESSOR installation) — but it
was permanently inert, because every token-resolving role held read-only registry
access, the conditional write failed AccessDenied, and the failure was deliberately
swallowed so a diagnosis could not break token resolution. The feature read as
implemented while doing nothing.

- Grant the webhook processor registry WRITE and pass the recorder through, so the
  latch actually lands. It is the right holder: it is the path a revoked workspace
  hits on every event.
- `markWorkspaceRevoked` now returns WHETHER it latched. That boolean is the dedup
  key — only the caller whose conditional write applied announces, so one
  revocation produces one alert rather than one per inbound event.
- New `linear-revocation-alert.ts` publishes that one announcement to the existing
  operational SNS topic, naming the workspace and the exact recovery command.
  SNS is deliberately the channel: the dead credential is Linear's own, so a Linear
  comment cannot report it — that is precisely the path that no longer works.
- Vault parity: a revoked grant surfaces on the AgentCore path as "consent
  required" rather than `invalid_grant`, so a detector keyed on the error shape
  would go quiet exactly when a workspace moved onto the vault. The vault resolver
  now returns a TYPED result instead of collapsing "needs consent" and "throttled"
  into one null (the silent-success-masking shape the repo lints against), and the
  resolver reports it — but only when NO path can produce a token, so a
  vault-dead/SM-alive workspace is not marked revoked and taken offline.
- Announcing stays best-effort and never throws; the latch has already landed, so
  `platform doctor` can explain the state even if notification fails.

Two things the work surfaced:
- The CMK-encrypted topic needs `kms:GenerateDataKey*`/`Decrypt` as well as
  `sns:Publish`. `grantPublish` does not grant the key, and since announcing is
  best-effort the failure would have been swallowed — a new dormant feature in
  place of the one being fixed.
- The added grants pushed the processor role past IAM's inline-policy size limit,
  so CDK now spills an `AWS::IAM::ManagedPolicy` overflow policy. Mapped it in the
  bootstrap action map; caught by tightening the synth-coverage guard to reject ANY
  unmapped type on the gated path, root or nested.

Full CDK suite green (4189 tests, 205 suites).
…t invalid_grant (#812)

The revocation branch could never run. It tested `error === 'invalid_grant'` — what
RFC 6749 specifies — and Linear does not send that. Linear answers a dead refresh
token with HTTP 400 and:

  { "error": "invalid_request", "error_description": "Refresh token revoked" }
  { "error": "invalid_request", "error_description": "Invalid refresh token" }

so every real revocation was filed as a generic `failure`. The permanent-rejection
path never executed, the registry was never marked, and no operator was ever
notified: detection that existed and could not fire. This is a SECOND, independent
reason the feature was dormant — fixing the missing registry-write grant alone would
not have made it work.

Found by live testing, not review: unit tests mocked the token endpoint with the
error code the test author assumed. Confirmed two ways — against the token-lineage
logs from the #807 investigation (maguireb's real death was `invalid_request` +
"Refresh token revoked"), and by deliberately driving a refresh with a bogus token
through the deployed Lambda.

`isRefreshTokenRejection` now accepts `invalid_grant` (in case Linear aligns with the
RFC) OR a 400 whose description names the refresh token. `invalid_request` alone is
deliberately NOT enough — it is also what a malformed request, i.e. our own bug,
returns, and marking a workspace revoked for that would take a working workspace
offline. Tests cover both observed payloads, the RFC form, a generic
`invalid_request`, and transient 5xx/429.
…hree I wired (#812)

Live test caught this: the permanent-rejection branch ran, logged
"workspace requires re-onboarding" — and still did not latch or announce, because
the recorder was threaded through call sites and the discovering caller was not one
of them. Seven modules resolve Linear tokens inside the webhook processor; I had
wired three. The one that found it was `linear-feedback`, which is a likely
discoverer precisely because posting a reply is often the first thing needing a
token.

Threading it per-caller is the wrong shape: every future caller has to remember,
and forgetting is silent. The resolver now builds the recorder itself from the
DynamoDB client and table name it already holds, so every path — present and
future — is covered. A caller-supplied recorder still wins, which is what tests use.

Gated on `LINEAR_REVOCATION_RECORDING`, set ONLY on the webhook processor role that
holds registry write. Everywhere else the conditional write would fail AccessDenied
and be swallowed — the inert-but-looks-implemented state this issue exists to
remove, and the reason the resolver deliberately never defaulted this before.

Also breaks a would-be import cycle: the alert module no longer imports the
resolver. The resolver owns the latch and calls a pure SNS `announceRevocation`,
which declares its own input type. Tests follow that split — dedup is asserted on
the latch in the resolver suite, message content on the announcement.

Full CDK suite green (4192 tests, 205 suites).
…-back (#809)

The hosted consent page only fixed the SECOND onboarding step. `vault-setup` layers
onto an already-onboarded workspace, and the prerequisite `bgagent linear setup`
still required a localhost listener — so on a cloud desktop, SSH box, or container
you were blocked at step 1 and never reached the part that had been fixed. Half a
feature: the localhost dependency was moved, not removed.

- The consent page now serves BOTH flows. The vault 3LO bounce arrives with
  `session_id`; the direct OAuth redirect arrives with `code` (+ `state`). One
  registered URL therefore REPLACES the localhost callback instead of merely
  supplementing it.
- `bgagent linear setup <slug> --hosted` sends the redirect to that page and stops,
  printing what to paste back. `--code <code>` finishes the exchange.
- New `linear-pending-consent.ts` carries the PKCE verifier + `state` between those
  two invocations. The code alone cannot complete a PKCE exchange, and `state` still
  has to be checked for CSRF. The verifier is a ONE-TIME SECRET, so the entry is
  0600, consumed on read (including on refusal), rejected past a 15-minute TTL, and
  keyed per workspace so two onboardings cannot clobber each other.
- `app-template` can now list the hosted page (`--hosted-consent-url`) and, when it
  cannot, says plainly that localhost will not work on a cloud desktop and names the
  flag that does — the instructions have to match reality or they send an operator
  into the "Invalid redirect_uri" dead-end this issue already produced once.

Localhost remains the default for same-machine use; nothing about that path changed.

Tests: page behaviour executed under vm for the code branch (including a hostile
code rendered as text, and session_id winning when both are present) and the full
pending-consent contract. CDK 4195 / CLI 780 green.
The consent page is served from a GENERATED CloudFront domain, and with --hosted
that URL is registered as a Callback URL on the Linear app. It changed twice during
development — the domain is stable only while the distribution is, and the
distribution is replaced whenever the consent page is recreated (flag toggled off
and on, nested stack recreated). A stale registration presents as the same
'Invalid redirect_uri' dead-end that already cost an onboarding attempt once, with
nothing pointing at the cause.

Both --hosted paths now print the URL with that caveat and the remedy: Linear
accepts several callback URLs, so adding the new one is safe and the stale entry is
harmless. Surfacing it beats an operator rediscovering it.

A stable custom domain would remove the caveat entirely but needs a hosted zone and
certificate — out of scope here, worth noting as the real fix.
…nding them (#809)

`app-template` is the FIRST command an operator runs, and it required
`--hosted-consent-url` — a CloudFormation output — plus a second callback URL
that AgentCore mints at provider-create time. Both were things the operator had
to go and find before the command that explains onboarding would tell them
anything useful.

It now resolves both itself:
  - the hosted consent page from the stack's LinearVaultConsentUrl output
  - the vault provider callback via GetOauth2CredentialProvider, given --slug

Every lookup is best-effort. An unconfigured CLI, absent credentials, an
undeployed stack, a flag-off deploy, and a not-yet-created provider all degrade
to a NOT FOUND line that says what is missing and how to get it, so the command
still renders offline. The flags remain as overrides, and --offline skips the
lookups entirely.

Each URL is now labelled with the command that redirects to it. Registering the
wrong subset presents as an opaque Linear "Invalid redirect_uri", and an
unlabelled list gave no way to tell which entry a failing command wanted — the
exact dead-end that cost one onboarding attempt.
`Application name` was hardcoded to `bgagent`, so anyone running ABCA under
their own branding had to notice the template was wrong and edit around it.
`--app-name` now sets it, and the GitHub username Linear requires for actor=app
is DERIVED from that name — one choice instead of two identifiers to keep
consistent. `--bot-name` still overrides.

The name is sanitized to something GitHub accepts (lowercase, single inner
hyphens, 39 chars) and falls back to the default when a name has no usable
characters at all, because an empty username field fails on Linear's side with
the misleading "Invalid redirect_uri" error rather than naming the real problem.
That fallback warns on stderr, since the template claims the username follows
the app name and would otherwise be quietly lying.

The template now also states that renaming does NOT change the trigger phrase.
`parseCommentTrigger` matches a fixed `@bgagent` token that is independent of
the app's name (verified against real workspace comments: app-authored comments
carry the display handle derived from the application name, while the trigger
token is plain text in the body). Without saying so, renaming yields an agent
that looks right and answers nothing.

Annotations are pinned to a column so operator-chosen values of differing
lengths do not leave a ragged edge.
…ired (#809)

The app template and setup guide both asserted that a "GitHub username" field
and the app's Webhooks toggle are REQUIRED for actor=app, and blamed a blank
username for Linear's "Invalid redirect_uri parameter for the application"
error. Neither claim holds up:

  - Linear's OAuth documentation describes no GitHub-username field, and its
    `Application` GraphQL type exposes only clientId, description, developer,
    developerUrl, id, imageUrl and name.
  - ABCA's events arrive through the WORKSPACE webhook the operator creates
    separately (`bgagent linear webhook-info`), not the app's own toggle. The
    template already conceded the webhook URL is "unused by the OAuth dance"
    while still calling it required.

The cost of the wrong attribution is real: it sends operators to fix a field
that may not exist while the actual cause — a callback URL that is not
registered on the app — stays broken. That cause is the one seen in practice,
including for the AgentCore vault, whose provider callback is a SECOND URL the
app must list.

So the error's guidance now points at the registered-URL list (all URLs present,
one line each, no wildcards), the username line is marked "only if your form
shows this field", and the webhook note explains what actually delivers events.
Also records that actor=app and the `admin` scope are mutually exclusive, which
Linear documents and the template did not mention.

Tests now assert the corrected facts and that the two false REQUIRED claims stay
gone, so they cannot creep back.
)

Checked the template field-by-field against the real app-creation form. It was
both longer than anyone would read and wrong in ways that cost onboarding
attempts:

  - "GitHub username" does not exist. The form is icon, application name,
    developer name, developer URL, description, redirect URIs. Removed the field,
    the --bot-name flag, and the name-to-username derivation behind it.
  - The field is "Redirect URIs" ("All OAuth redirect URIs, separated with
    newlines"), not "Callback URLs" — an operator scanning for our label would
    not find it.
  - The webhook URL was printed as https://example.com/placeholder alongside "you
    do NOT need to subscribe to any events", which is true of the OAuth dance
    alone and leaves an app that can never deliver an event. It now prints the
    real endpoint, derived from the configured API URL (no AWS call), and asks
    for Issues + Comments.
  - Webhooks CAN be configured on the app; the previous commit's claim that ABCA
    does not use the app-level toggle was wrong. Either the app's own webhook or
    a workspace webhook works, since the receiver verifies whichever signing
    secret it is given.

Adds the trap that actually produced "Invalid redirect_uri" here: redirect URIs
are matched as exact strings, so a missing trailing slash fails — the hosted
consent URL ends in one. Also flags that ticking Issues without Comments
silently disables the comment trigger while labels keep working, which is the
kind of half-broken state nobody thinks to check.

Two `setup` paths still said "Subscribe to: Issues", contradicting webhook-info.
Both now name Comments and why.

Net: 55 lines to 45, every URL real, every claim checked.
Reverts the previous commit's claim that the OAuth app's own webhook toggle is a
valid place to configure ABCA. Checked against the running deployment: the live
subscription is a WORKSPACE webhook (Issue + Comment, enabled) and the app's
webhook is off. That is the configuration in use, and the only one verified.

Worse, the previous commit printed ABCA's endpoint under the app's "Webhook URL"
field. Following that alongside the documented workspace webhook yields two
subscriptions to one endpoint: every event delivered twice, under two different
signing secrets, while ABCA stores one per workspace — so the duplicate both
doubles the work and fails to verify. Now stated as a trap, since pasting the URL
in both places looks harmless.

The endpoint is still resolved and printed, but labelled as the workspace webhook
to create, so nobody has to go and find it.

Also trimmed the trap list back under the length budget the rewrite set. Length
is a real defect here: the list had grown to the point an operator stopped
reading and left the webhook URL blank because an earlier version called it a
placeholder.
…ace one (#809)

One webhook, set up while creating the app, instead of an app plus a separate
workspace subscription in a later step. Nothing in the receiver cares which
surface delivered the event: it routes on the payload's `organizationId` and
verifies the HMAC against whichever signing secret was stored for that
workspace, so the app's own secret works unchanged.

This also removes a whole failure class from onboarding — the per-workspace
signing secret that a second `linear setup` run could overwrite, leaving the
first workspace returning 401.

The template prints the real endpoint in the app's Webhook URL field, asks for
Issues + Comments, and says to keep the signing secret for `setup`. The guide
now treats the app-level webhook as the path and a workspace webhook as the
alternative — explicitly an either/or, since configuring both delivers every
event twice under two secrets with only one able to verify.

Multi-workspace note: an app webhook has one signing secret shared by every
workspace the app is installed on, where workspace webhooks get one each. Both
work, because the secret is stored per workspace either way.
…ide (#809)

Two spots still told operators to "rely on the Issues + Comments webhook events
(step 4)" and to "keep only the workspace webhook", which contradicts the
app-level webhook the template now configures. Both now name the App events
checkboxes as the thing to untick and say to keep the webhook itself enabled with
the Issues + Comments data-change events.
…ay licence block

Three nits from the approving review, plus the minimal form of a fourth.

**Dangling `{@link}` (`operational-alerts.ts`).** `grantPublish`'s docstring pointed at
`suppressPublisherKmsWildcard`, which exists nowhere in the repo — that reference was its
only occurrence. It matters more than a stale comment usually would, because it is the
pointer for a gotcha that breaks synth when missed: once a role's inline policy overflows,
CDK spills the excess into an `OverflowPolicy` created after `applyToChildren` has
resolved, so a construct-scoped suppression silently misses it. Now names the real
mechanism, `OVERFLOW_SUPPRESSIONS` in `cdk/src/stacks/agent.ts`.

**`vaultMetadata` did not cover the builder it was written for.** Its docstring says every
channel_metadata builder must spread it, and a source-level guard enforces that — but the
guard keys off the object-LITERAL form, and the primary label-trigger path assigns onto an
existing object and hand-rolled the fields. So the one path the helper exists for was the
one path its own guard could never see. It now calls the helper, and the gap is covered by
a BEHAVIOURAL assertion on the channel_metadata that reaches `createTaskCore`, because a
source pattern cannot express this builder's shape.

Demonstrated rather than asserted: dropping the vault fields from the label path fails the
new behavioural test while the structural guard still passes. Both checks are kept — they
fail for different reasons. The structural one catches a builder nobody exercised; the
behavioural one catches a builder the pattern cannot describe. A second test pins the
absent-provider case, so spreading unconditionally (which would hand the agent a provider
name for an SM-only workspace) also fails.

`linear_workspace_id` keeps its existing gating rather than moving to the helper — the
helper owns the two vault fields, and widening that field's conditions is a behaviour
change the review did not ask for.

**Duplicated licence header** removed from `linear-webhook-processor.test.ts` — an editing
artefact that left a second full MIT block below the imports.

**`sessionUri` is now optional, not enforced.** The tagged union still let the
`consent-required` arm be built with an empty string. Made `readonly sessionUri?: string`
with the sentinel dropped, deliberately NOT throwing: AgentCore models the field as
optional on the token response, so absence is the service's contract, and refusing the
consent would reject a flow the interactive path completes from the pasted id. Optionality
puts the absence in the type where a non-interactive caller must handle it.

Deferred, with reasons on the PR: agent-side token renewal (needs a remint-on-401 design,
not a cache tweak), the `state`-absent path (deserves full persisted-record validation
rather than one more guard), and renaming `linear-revocation-recorder.test.ts` (churn —
the file names the behaviour it tests, not the module it imports).
@isadeks

isadeks commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Cleanup at 706e91f4

Thanks — thorough pass, and the verification style (checking the un-latch, the announcement-claim removal, and the CodeQL findings yourself rather than taking the dismissed reviews' word) is the reason two of these were worth acting on rather than filing.

Fixed

Nit 1 — dangling {@link}. Confirmed: suppressPublisherKmsWildcard appears nowhere in the repo, and that docstring was its only occurrence. Now names OVERFLOW_SUPPRESSIONS in cdk/src/stacks/agent.ts. Agreed this is worse than an ordinary stale comment, since it is the pointer for something that breaks synth when missed.

Nit 3 — vaultMetadata did not cover its own builder. The best of the seven, and exactly right: the guard keys off the object-literal form, the label path assigns onto an existing object, so the one path the helper was written for was the one path its guard could never see. It now calls the helper, covered by a behavioural assertion on the channel_metadata that reaches createTaskCore.

Demonstrated rather than claimed — dropping the vault fields from that path now fails the behavioural test while the structural guard still passes:

✕ LABEL-trigger path actually emits the vault fields (behavioural)
✓ each builder writing the workspace slug spreads vaultMetadata

Both checks are kept, because they fail for different reasons: the structural one catches a builder nobody exercised, the behavioural one catches a builder the pattern cannot describe. A second test pins the absent-provider case, so spreading unconditionally — which would hand the agent a provider name for an SM-only workspace — fails too. linear_workspace_id keeps its existing gating; widening that field's conditions would be a behaviour change you did not ask for.

Nit 6 — duplicated licence block removed.

Nit 2 — taken in the optional form, not the throwing one. You offered either; optional is the right half. AgentCore models sessionUri as optional on the token response, so an absent value is the service's contract rather than a fault, and throwing would refuse a consent the interactive flow still completes from the pasted id. Now readonly sessionUri?: string with the ?? '' gone, which puts the absence in the type where a non-interactive caller has to handle it instead of behind an empty string that reads as a session.

Deferred, with reasons

Nit 4 — agent-side token caching. Real, and I agree it is not a regression (the SM path behaves identically). Deferring because the fix is a design, not a tweak: caching with an expiry still guesses the vault's TTL, so the honest shape is remint-on-401 in linear_reactions.py, which needs its own change and its own test. Worth an issue rather than a hurried cache.

Nit 5 — state-absent path. Agreed it costs nothing to fail closed, but a one-off guard on pending.state treats one field of a record whose other fields are equally unvalidated. If the persisted consent record deserves validation it deserves it as a record, which is a bigger change than this PR should carry.

Nit 7 — test file name. Declining: linear-revocation-recorder.test.ts names the behaviour under test rather than a module it imports, which I would rather keep than rename for filename symmetry.

The adjacent finding is the one I would act on next

cli/src/linear-oauth.ts:279-283 stringifying the raw /oauth/token body into a CliError — you are right that it is untouched here (git diff origin/main...HEAD confirms), and right that it matters: a 200 carrying access_token but missing expires_in or scope prints an access token to the operator's terminal and into any CI log capturing it. Happy to file that as a P1 follow-up with a redaction fix if you would like it tracked rather than left in the review thread.

CI: local mise run build green at this HEAD (CDK 4448 / CLI 895 / agent 1782), and security:deps is now genuinely clean — #844 merged and this branch picked it up, so the repo-wide browserslist advisory that had every PR red is gone here too.

… is off-contract

`exchangeAuthorizationCode`/`refreshAccessToken` interpolated
`JSON.stringify(body).slice(0, 200)` when the response failed
`isLinearTokenResponse` — and the first 200 characters of a token response ARE the
token. The guard requires `expires_in` and `scope`, so a 200 carrying a perfectly good
`access_token` alongside, say, a string `expires_in` printed the credential to the
operator's terminal and into any CI log capturing stdout, where it outlives the session
that leaked it. Both the access and refresh tokens were in range.

Now reports which required field is wrong, with the types, plus the key NAMES present —
never a value. That is strictly more useful for diagnosing a contract change than a
truncated blob, so the redaction costs no debuggability.

Surfaced by review as adjacent and pre-existing, with a note that the function is
untouched by this PR. True of the function; not of the file — this PR already adds 88
lines to it, and a credential-disclosure path in the Linear OAuth exchange is the same
concern the PR exists to improve. So it is fixed here rather than deferred, in its own
commit.

The regression test asserts the negative directly — neither token value may appear in
the message — and is mutation-proven: restoring the raw-body interpolation fails it.

NOT fixed here: `cli/src/jira-oauth.ts:327` has the identical pattern. That file is not
in this PR's diff, and pulling an unrelated surface into a large vault change is the
scope creep the above reasoning does not license. It needs the same treatment in its own
change.
@isadeks

isadeks commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

The adjacent finding is fixed here after all — 2ced095e

I initially said this belonged in a follow-up, quoting your own framing that the function is untouched by this PR. That is true of the function and not of the file: this PR already adds 88 lines to cli/src/linear-oauth.ts, so the scope argument I was leaning on does not hold, and a credential-disclosure path in the Linear OAuth exchange is the same concern the PR exists to improve. Fixed in its own commit.

Your read of the mechanism was exact, and the dangerous case is subtler than "missing token": isLinearTokenResponse requires expires_in and scope, so a 200 carrying a perfectly good access_token alongside — say — a string expires_in failed the guard and printed the credential. Both the access and refresh tokens sat inside the 200-character window.

The message now names the offending field and its expected type, plus the key NAMES present, and never a value:

Linear /oauth/token returned an unexpected shape for the code exchange:
expires_in expected number, got string
(keys present: access_token, expires_in, refresh_token, scope,
token_type)

That is more useful than a truncated blob for diagnosing a contract change, so the redaction costs no debuggability. The regression test asserts the negative directly — neither token value may appear — and is mutation-proven: restoring the raw-body interpolation fails it.

One thing I did not fix here, deliberately. cli/src/jira-oauth.ts:327 carries the identical pattern, so this is a class of two rather than a one-off. That file is not in this PR's diff, and pulling an unrelated surface into a large vault change is exactly the scope creep the reasoning above does not license. It wants the same treatment in its own change — happy to open that as a small PR with an issue, the way #844/#845 went, if you would rather have it tracked than left in this thread.

mise run build green at this HEAD (CDK 4448 / CLI 896 / agent 1782).

@scottschreckengaust scottschreckengaust left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Verdict — Request changes (one blocker)

Re-review at 2ced095e against my prior approve-with-nits at 4db95835 (dismissed by the two new pushes). The delta itself is clean and strictly improving — describeTokenShape closes a real credential-in-stdout leak, sessionUri becoming optional matches the AgentCore contract, and the label-trigger path now routes through the shared vaultMetadata helper. My earlier pass, however, did not check the new vault SDK import against this repo's own Lambda-bundling rule, and that check fails: @aws-sdk/client-bedrock-agentcore is now statically imported into the shared Linear resolver chain, which is bundled into at least six Lambdas that externalize @aws-sdk/* — the exact configuration task-api.ts:565-572, task-orchestrator.ts:390-395 and linear-identity-vault.ts:98 all document as broken for this client. That is the blocker; everything else is non-blocking.

The engineering quality is otherwise unusually high: the tri-state VaultTokenResult, the announce-before-latch with an independent revocation_announced_at claim, the installed_at-conditioned latch, and the UpdateItem-only registry grant are all correct and well argued in place.

2. Prior blockers / open threads

Claim Status Evidence at 2ced095e
B1 — transient Secrets Manager error latches a healthy workspace revoked fixed (re-verified, not trusted) cdk/src/handlers/shared/linear-oauth-resolver.tsgetOauthSecretForResolve returns null only on definite absence and rethrows otherwise; recordRevocation fires only inside the if (!fetched) branch.
B2 — account-wide bedrock-agentcore-identity!* client-secret read fixed cdk/src/constructs/linear-identity-vault.ts:320-332 scopes to bedrock-agentcore-identity!default/oauth2/bgagent-linear-oauth-*.
ayushtr nitVaultConsentStep empty-string sentinels fixed cli/src/linear-vault.ts is now a kind-tagged union; the delta additionally made sessionUri optional.
my nit — dangling {@link suppressPublisherKmsWildcard} fixed in 706e91f4 repointed at OVERFLOW_SUPPRESSIONS.
my nitvaultMetadata structural guard could not see the label-trigger builder fixed in 706e91f4 behavioural test added; all five builders now spread the helper.
CodeQL #41 (URL substring) / #42 (fingerprintToken) still open, both benign #41 is a toContain on a fixture host in cli/test/commands/linear.test.ts; #42 is a 12-hex log-correlation tag over a high-entropy token, never compared to a stored secret. Please dismiss them with a reason so the next reviewer can tell them from a regression, and replace the real-looking d2ud1woydykuxp.cloudfront.net fixture with a doc-safe placeholder.

No prior blocking claim regressed. One claim raised by a lens this round I checked and dropped: add-workspace cannot silently erase a vault binding — the ScanCommand dup-check at cli/src/commands/linear.ts:1685-1697 throws for an already-registered workspace before the PutCommand.

3. Vision alignment ✓

Advances fire-and-forget and bounded blast radius (VISION.md) and RFC #249 / ADR-016 Phase 1: a long-lived Linear credential in Secrets Manager is replaced by short-lived vault-minted tokens (#809), and a dead grant becomes an attributable, deduplicated, out-of-band operator alert instead of a silently failing user task (#812). Default-off behind enableLinearIdentityVault. Governance ✓#809 and #812 both carry approved and are self-assigned; branch feat/809-linear-identity-vault-pr is conventional.

4. Blocking issues

B1. @aws-sdk/client-bedrock-agentcore is externalized on every Lambda that reaches the new vault codecdk/src/constructs/linear-integration.ts:225 (externalModules: ['@aws-sdk/*'], inherited by attachmentScreeningBundling, which is what the webhook processor uses at :328).

The import chain is fully static, no dynamic import() anywhere in it:

linear-webhook-processor.ts / fanout-task-events.ts / orchestration-reconciler.ts /
iteration-heartbeat-sweep.ts / github-webhook-processor.ts
  → orchestration-channel-factory.ts:59  (static)
  → orchestration-channel-linear.ts:50   (static)
  → linear-oauth-resolver.ts:35          (static)
  → linear-vault-token.ts:39-43  import { BedrockAgentCoreClient, ... } from '@aws-sdk/client-bedrock-agentcore'

Every one of those five entry points is bundled with externalModules: ['@aws-sdk/*']linear-integration.ts:225, fanout-consumer.ts:200, orchestration-reconciler.ts:98, iteration-heartbeat.ts:83, github-screenshot-integration.ts:197 (plus jira-integration.ts:216 / slack-integration.ts:157, whose handlers reach the same factory). This repo has hit this exact wall twice and wrote it down both times:

@aws-sdk/client-bedrock-agentcore in particular has shipped new commands … that are not in the runtime's bundled SDK, so externalizing it causes Lambdas to throw <Command> is not a constructor at runtime — a silent failure mode — task-api.ts:565-572

The bedrock-agentcore-control SDK is not in the Lambda runtime, so it must be bundled (the repo default externalizes @aws-sdk/*, which we override). — linear-identity-vault.ts:97-98

Two possible runtime outcomes, both bad, and neither reachable by any test in this PR (jest disables bundling; CI never deploys):

  • Client/commands present but stalemakeClient(BedrockAgentCoreClient, …) at linear-vault-token.ts:147 sits outside the function's try, so a missing constructor throws past the careful unavailable classification, out of resolveLinearTokenViaVault, and out of resolveLinearOauthToken (Step 1b at linear-oauth-resolver.ts:421-428 has no try). Token resolution for vault-onboarded workspaces fails hard, not gracefully.
  • Package absent from the runtime image → the top-level require fails at module load, so the Linear webhook processor (and fan-out consumer, reconciler, heartbeat, screenshot processor) fail at cold start, for all workspaces, whether or not enableLinearIdentityVault was passed. That is a total Linear-integration outage introduced by a flag-gated feature.

Fix: replace externalModules: ['@aws-sdk/*'] with the enumerated stable-client list already used by task-api.ts:574-582 / task-orchestrator.ts:405-414 on every construct whose entry reaches orchestration-channel-factory.ts, so client-bedrock-agentcore is bundled. Add a synth-time or unit assertion that no function whose entry transitively imports linear-vault-token.ts lists @aws-sdk/* as external — this failure mode is invisible to the current suite, and the SM fallback would mask the degraded variant of it in production.

5. Non-blocking suggestions

MAJOR — setup strips the vault binding whenever it does not take the vault branch. cli/src/commands/linear.ts:928: useVault = Boolean(consentPageUrl) && !resumed. alreadyOnVault is computed at :927 and correctly used at :954 to refuse a substrate move on a provider-upsert error, but not here. If the stack output is absent (e.g. the last cdk deploy omitted --context enableLinearIdentityVault=true, which must be repeated every time) or the run is a --code resume, a vault-managed workspace takes the direct-OAuth path and the full-item PutCommand at :1305 writes the row without provider_name / vault_user_id, silently demoting it back to a long-lived Secrets-Manager credential behind a green "Setup complete", orphaning the vault provider. The printed reason ("AgentCore Identity not available in <region>") is also a fabricated diagnosis — nothing probed regional availability, only a missing stack output. Carry the prior fields forward, or fail fast when alreadyOnVault && !consentPageUrl.

MAJOR — the CLI minter collapses "vault unreachable" into "grant revoked". cli/src/linear-vault.ts:264 returns null from a bare catch, and cli/src/linear-auth-health.ts:342 adds .catch(() => null), so AccessDenied / throttling / no AgentCore in region all land on state: 'revoked' at :347 with the remedy bgagent linear setup <slug> — which per this PR's own comment at commands/linear.ts:975-985 can replace the Linear installation and invalidate a working grant. platform-doctor.ts renders that as a hard fail. The module 30 lines above already does this right (state: 'unknown' when it has no way to query the vault), and cli/test/linear-auth-health.test.ts:278 asserts exactly that principle. Mirror the cdk-side VaultTokenResult union in the CLI (isVaultUnavailableError is already in that file) and map unavailableunknown. The nosemgrep rationale at linear-vault.ts:265 ("distinguishing 'no grant' from 'API error' would not change the caller's action") is falsified by the caller this PR adds — please update or drop it.

MAJOR — the un-latch is not env-gated the way the latch is. cdk/src/handlers/shared/linear-oauth-resolver.ts:444: defaultRevocationRecorder is gated on LINEAR_REVOCATION_RECORDING precisely because a read-only role would fail AccessDenied and swallow it, but clearWorkspaceRevocation is called unconditionally. Only the webhook processor holds dynamodb:UpdateItem; the fan-out consumer, orchestrator, reconciler, heartbeat and screenshot processor get grantReadData + grantMintToken. When one of them re-probes a vault_consent_required row and the vault answers with a token, the clear is denied and logger.error fires on every event, while platform doctor keeps reporting a workspace the resolver is successfully serving. Gate it on the same env flag, or special-case AccessDeniedException to warn.

MAJOR — the vault cache-key constants are quadruplicated with only "keep in sync" comments. cdk/src/handlers/shared/linear-vault-token.ts:48/64/76, agent/src/config.py:71/75/83, cli/src/linear-oauth.ts:34, cli/src/linear-vault.ts:50/255, cdk/src/stacks/agent.ts:994. contracts/constants.json exists for exactly this (approval_gate_cap triplication) and has no linear_vault block; there is no parity test. The code names the consequence itself — a one-token divergence makes every resolve a cache miss, which post-#812 is reported as consent-required and can latch a healthy row revoked. Add a linear_vault block plus a parity test alongside cli/test/constants-parity.test.ts.

MAJOR (tests) — one test claims coverage its assertion does not provide. cdk/test/handlers/shared/linear-revocation-recorder.test.ts:211, "the recorded reason distinguishes Linear refusing from a vault inference", asserts only ConditionExpression).toContain('installed_at = :installed') — nothing about the reason. No test drives the default recorder with source: 'vault-consent-required', so the reason ternary at linear-oauth-resolver.ts:301-303 is unverified: collapse it to always write refresh_token_rejected and the suite stays green, while the self-heal re-probe (:397-398) stops matching and a wrongly-latched workspace becomes permanently offline — the exact property #812 adds. Capture ExpressionAttributeValues in the harness and assert :reason on both paths.

MAJOR (docs) — vault + compute_type=lambda-microvm is unsupported and undocumented. docs/guides/LINEAR_SETUP_GUIDE.md:24 recommends --context enableLinearIdentityVault=true with no note that the combination hard-fails synth at 505 resources; the only record is a test title (cdk/test/stacks/agent.test.ts:1721) and the PR body. ADR-016 is silent too, and agent.ts still attaches grantMintToken + LINEAR_VAULT_ENABLED to lambdaMicrovm.executionRole, which reads as supported. Add the incompatibility to the guide (+ mise //docs:sync) and to ADR-016's scope table, and ideally fail fast in AgentStack naming both flags instead of letting the resource counter throw.

Nits

  • cdk/src/handlers/shared/linear-oauth-resolver.ts:398 — the re-probe guard uses the raw literal 'vault_consent_required' instead of VAULT_CONSENT_REVOCATION_REASON, defeating the constant whose docblock says the guard is one of the three places that must agree. Related: type the reason (type LinearRevocationReason = 'refresh_token_rejected' | 'vault_consent_required') instead of reason: string at :620, and mirror it into the CLI row type — cli/src/linear-auth-health.ts:283 re-declares the literal independently.
  • cdk/src/stacks/agent.ts:633 — "Workload name matches the LinearIdentityVault construct's fixed abca_linear_oauth" is stale: linearVaultWorkloadName(this) (agent.ts:122-130) returns abca_linear_oauth_<StackName> and is passed into the construct, which has no fixed default. Renaming the identity orphans every consent, so this is the worst comment in the file to be wrong.
  • cdk/src/handlers/shared/linear-revocation-alert.ts:29 — the file header still says "The registry latch is the dedup key: only the caller whose conditional write actually flipped active → revoked announces", which announceRevocation's own docstring 40 lines below explicitly contradicts (the dedup key is the independent revocation_announced_at claim, and the resolver announces before latching). Acting on the header reintroduces the bug the claim was added to fix. Same class: markWorkspaceRevoked's "that boolean is the dedup key for notification" — no production caller consumes the return value.
  • cdk/src/constructs/linear-vault-consent-page.ts:32 and cli/src/commands/linear.ts:869 reference a bgagent linear setup --hosted flag that does not exist (the options are --code, --hosted-consent-url, …; the hosted page is selected automatically whenever the stack publishes LinearVaultConsentUrl, and the guide says "There is no flag").
  • cli/src/linear-auth-health.ts:38 — "Read-only: this never consumes a refresh token" is now false for the whole module: :415 calls verifyLinearRefreshAndPersist and :498 issues PutSecretValueCommand. Qualify it as "read-only by default; verifyRefresh opts into a real rotation".
  • cdk/src/handlers/shared/linear-oauth-resolver.ts:308 — "the operator has still been told, because the announcement above already went out" is untrue on the no-topic path (:319-327 records without announcing), which is the total-silence case.
  • cdk/src/handlers/linear-webhook-processor.ts:910 — points at "the source-level guard below vaultMetadata"; the guard lives in cdk/test/handlers/linear-webhook-processor.test.ts. Name the file.
  • agent/src/config.py:135platform_client("bedrock-agentcore", …) sits outside the try that catches (ClientError, BotoCoreError), so UnknownServiceError / InvalidRegionError escape a function documented to return "" on any failure and abort the task before the 👀 acknowledgement. Move it inside.
  • cdk/src/handlers/shared/linear-oauth-resolver.ts parseOauthSecret in 'without-grant' mode returns StoredOauthToken | null while deliberately skipping validation of access_token/refresh_token/expires_at/scope; give it mode-keyed overloads so a future caller cannot get a grant from the grantless door. Also add readonly revoked_at?: string to RegistryRow, which markWorkspaceRevoked writes and the CLI reads back.
  • cli/src/commands/linear.ts:100resolveLinearVaultWorkloadName's .catch(() => null) turns a DescribeStacks AccessDenied into the hardcoded abca_linear_oauth, which no stack uses now that the name is stack-scoped; the resulting error tells the operator to redeploy a correct stack.
  • Comment proportionality: several new docblocks narrate this PR's own review history ("Third occurrence of the same mistake in this change", "LIVE-CORRECTED", ~30 lines in linear-integration.ts:294-324). The invariants are worth keeping; the archaeology belongs in the PR description. Relatedly, the source-line-adjacency assertion in linear-webhook-processor.test.ts:1092 fails on a harmless key reorder — the new behavioural test is the better guard.
  • cli/test/commands/linear.test.ts:294 — replace the real deployment hostname d2ud1woydykuxp.cloudfront.net with https://d111111abcdef8.cloudfront.net/; that also removes what CodeQL #41 latched onto.

6. Documentation

docs/guides/LINEAR_SETUP_GUIDE.md and ADR-016 are substantively updated (ADR-016 explicitly supersedes its own earlier bedrock-agentcore-identity!* guidance — a documented trade, not a silent one), and the deleted LINEAR_PAK_MIGRATION_RUNBOOK.md was removed together with its Starlight mirror, its astro.config.mjs entry and its sync-starlight.mjs entry — no dangling references. Starlight mirrors are in sync. Missing: the vault + lambda-microvm incompatibility (above), and linearVaultHostedReturnUrl — an undocumented context knob that changes the OAuth return-URL allowlist on the workload identity and skips the managed consent page. Document it next to the other two flags with the requirement that it must be an origin the deployer controls.

7. Tests & CI

All checks currently pass. Coverage is genuinely strong: per-branch failure-path tests for the resolver, vault-token, revocation-alert, pending-consent and identity-provisioning handlers, behaviour-level rather than implementation-level assertions, and the delta's new test ("an off-contract 200 must not echo the access token") is exactly the right shape. Gaps: the reason-mapping test above; the agent-side mint request pins customParameters but not scopes / oauth2Flow / resourceOauth2ReturnUrl, so _LINEAR_VAULT_SCOPES can drift silently (agent/tests/test_config.py:387); no test pins that an absent sessionUri yields undefined rather than ''; no command-level test drives makeLinearCommand() setup, so the pasted-state correlation checks (:895, :1112) and resolveLinearVaultWorkloadName are unexercised. cdk/test/stacks/agent.test.ts:1595+ re-synthesizes the full AgentStack per test against the beforeAll convention in cdk/AGENTS.md:33 (#366) with no exemption comment — two of those tests share one context and could share one Template.

Bootstrap synth coverage: PASS with a gap. No cdk/src/bootstrap/policies/*.ts changed, so no version bump is owed; the three new CFN types are added to resource-action-map.ts and the auto-named overflow AWS::IAM::ManagedPolicy is covered by the existing policy/backgroundagent-dev-* statement. Gap: in cdk/test/bootstrap/synth-coverage.test.ts:158 the nested-stack path only asserts RESOURCE_ACTION_MAP membership — run findMissingBootstrapActions over nestedTypes too, or a mapped-but-unpermitted nested type still fails a clean-account first deploy.

8. Review agents run

Folded in: code-review, silent-failure, tests, types, comments, security-governance-docs. None omitted or failed. One reported MAJOR (add-workspace erasing provider_name) was dropped as non-reproducible — the dup-check throws first; two lenses' overlapping CLI-minter findings were merged. Limitation: the suites were not executed locally (the review worktree has no node_modules/.venv and installing would pollute shared state), so every claim here is static reasoning; CI is green, which does not cover B1 — jest disables Lambda bundling and no deploy runs in CI.

9. Human heuristics

  • Proportionality — concern. Several docblocks narrate this PR's review history rather than the code's contract (linear-webhook-processor.ts:638, linear-integration.ts:294-324, linear-oauth-resolver.ts:1117 "LIVE-CORRECTED"); once merged, "in this change" has no referent.
  • Coherence — concern. The design iterated (stack-scoped identity, claim-based dedup, opt-in refresh, automatic hosted page) and the code plus nearest docstring were updated while four file-header/overview comments kept describing the earlier design (linear-revocation-alert.ts:29, agent.ts:633, linear-auth-health.ts:38, linear-vault-consent-page.ts:32). One sweep of module headers, not four point fixes. Also linear-oauth-resolver.ts:398 bypasses the constant introduced for it.
  • Clarity — pass, with one edge. The tri-state unions, the installed_at-scoped conditions and the alerting rationale are exceptionally clear; resolveViaVault still carries a superseded docblock describing a string | null return (linear-oauth-resolver.ts:198-202).
  • Appropriateness — concern. The blast radius of B1 is larger than the flag suggests: an unconditional top-level SDK import inside a shared handler module makes a default-off feature able to break Lambdas that never enable it. A bundling assertion, not a comment, is the right guard.

Comment thread cdk/src/constructs/linear-integration.ts
Comment thread cli/src/commands/linear.ts
Comment thread cli/src/linear-auth-health.ts
Comment thread cdk/src/handlers/shared/linear-oauth-resolver.ts
Comment thread cdk/test/handlers/shared/linear-revocation-recorder.test.ts Outdated
Comment thread cdk/src/stacks/agent.ts Outdated
Comment thread cdk/src/handlers/shared/linear-revocation-alert.ts Outdated
Comment thread cdk/src/constructs/linear-vault-consent-page.ts Outdated
Comment thread cli/src/linear-vault.ts Outdated
Comment thread docs/guides/LINEAR_SETUP_GUIDE.md
@scottschreckengaust

Copy link
Copy Markdown
Contributor

B1 withdrawn — the hazard is pre-existing on main, not introduced here

I checked B1 mechanically instead of by static reading, and it does not hold. Bundling each entry with the same esbuild version and externalization CDK uses (0.28.1, external: ['@aws-sdk/*']), counting require("@aws-sdk/client-bedrock-agentcore") in the emitted CJS:

entry main 2ced095e
fanout-task-events 1 2
github-webhook-processor 1 3
linear-webhook-processor 0 1
orchestration-reconciler 0 1
iteration-heartbeat-sweep 0 1

Reproduce from cdk/:

./node_modules/.bin/esbuild src/handlers/fanout-task-events.ts --bundle \
  --platform=node --target=node24 --format=cjs --external:'@aws-sdk/*' \
  --outfile=/tmp/out.js
grep -c 'require("@aws-sdk/client-bedrock-agentcore")' /tmp/out.js

Two of the five entrypoints already ship that require on main, under the identical externalModules: ['@aws-sdk/*']fanout-consumer.ts:200 via shared/memory.ts (CreateEventCommand, RetrieveMemoryRecordsCommand) and github-screenshot-integration.ts:197 via agentcore-browser.ts (StartBrowserSessionCommand). Not tree-shaken; the require reaches the emitted bundle.

That falsifies both runtime scenarios I described:

  • "Package absent → cold-start failure → Linear outage regardless of the flag." If @aws-sdk/client-bedrock-agentcore were unresolvable in nodejs24.x, FanOutFn would be dead at module load on every invocation on main today and the memory path would never have worked. The runtime ships a pinned minor of the whole SDK v3 client set, not a curated subset (Building Lambda functions with Node.js — "a specific minor version of the AWS SDK for JavaScript v3, not the latest version").
  • "Stale client → <Command> is not a constructor." This needs the pin to lack GetWorkloadAccessTokenForUserIdCommand / GetResourceOauth2TokenCommand. Those are AgentCore Identity data-plane ops from the same client and the same launch wave as the Memory ops main already calls successfully through the externalized client. StopRuntimeSessionCommand — the one we actually got burned by (task-api.ts:565-572) — is a later addition to that same package. One version: either the pin predates AgentCore entirely, and main is broken, or it carries both.

I was also wrong that the flag doesn't contain the blast radius. linear-oauth-resolver.ts:419 gates the call on row.provider_name && workloadName && isVaultEnabled(), so a flag-off deployment constructs nothing. Only the module-level require is flag-independent, and that is the part main already proves resolves.

The one correct observation inside B1 survives, and it's two lines. linear-vault-token.ts:147 builds the client outside the try, in a function whose docstring says "Never throws" — so any SDK-shaped surprise escapes the unavailable classification and out of Step 1b, which has no try of its own. Moving makeClient(BedrockAgentCoreClient, …) inside the try turns that whole failure class into { kind: 'unavailable' } → Secrets-Manager fallback, which is the designed behaviour. That's item 1 below.

The bundling change itself (enumerated externals on every construct reaching orchestration-channel-factory.ts, plus a synth-time assertion) is real hardening, but it now belongs in its own PR scoped to cover main's pre-existing fanout / github-screenshot cases too — fixing it only on the constructs this PR touches would leave the identical pattern deployed and unasserted. I'll file that.

What I'd like fixed here, and what I'm happy to see as a fast-follow

Everything else in my review stands as written, but most of it is fast-follow-able. My line was: defer anything whose cost is paid by the next maintainer reading code; don't defer anything whose cost is paid by a user or operator who can't see it.

Fix in this PR (seven items, five of them one-to-four-liners):

  • 1. cdk/src/handlers/shared/linear-vault-token.ts:147 — move makeClient inside the try. Retires B1.
  • 2. cli/src/commands/linear.ts:928useVault ignores the alreadyOnVault you compute one line above and use correctly at :954. A missing stack output or a --code resume takes the direct-OAuth path, and the full-item PutCommand at :1305 spreads provider_name / vault_user_id conditionally — so they're absent, demoting a vault-managed workspace back to a long-lived Secrets-Manager credential and orphaning the vault provider, behind a green "Setup complete". Carry the prior fields forward, or fail fast when alreadyOnVault && !consentPageUrl. The printed "AgentCore Identity not available in <region>" is also a fabricated diagnosis — nothing probed regional availability, only a missing stack output.
  • 3. cli/src/linear-vault.ts:264 + cli/src/linear-auth-health.ts:342 — the bare catch { return null } plus .catch(() => null) maps AccessDenied / throttling / no-AgentCore-in-region onto state: 'revoked' at :347, which platform-doctor.ts renders as a hard fail with the remedy bgagent linear setup <slug> — which per this PR's own comment at commands/linear.ts:975-985 can replace the Linear installation and invalidate a working grant. A transient error must not produce destructive remediation advice. The module 30 lines above already does this right, and cli/test/linear-auth-health.test.ts:278 asserts exactly that principle. Mirror the cdk-side VaultTokenResult union (isVaultUnavailableError is already in the file) and map unavailableunknown; update or drop the nosemgrep rationale at linear-vault.ts:265, which this PR's own caller falsifies.
  • 4. cdk/src/stacks/agent.ts:633 — one line. The comment says the workload name is a fixed abca_linear_oauth; linearVaultWorkloadName(this) (agent.ts:122-130) returns abca_linear_oauth_<StackName>. A maintainer who trusts the comment and "simplifies" the name orphans every consent, irreversibly.
  • 5. cdk/src/handlers/shared/linear-revocation-alert.ts:29 — one line. The file header states the inverse of announceRevocation's own docstring 40 lines below. Acting on the header reintroduces the bug feat(linear): detect, latch, and notify on revoked channel credentials — today a dead grant surfaces only as a failed user task #812 exists to fix.
  • 6. cdk/test/handlers/shared/linear-revocation-recorder.test.ts:211 — the test is titled for the reason mapping and asserts the ConditionExpression. This is the one category a fast-follow structurally cannot reach: the artifact left behind is a passing, well-named test, indistinguishable from the work being done. Capture ExpressionAttributeValues and assert :reason on both the Linear-refused and vault-inferred paths (~6 lines).
  • 7. Docs + scanner hygiene (~4 lines total)docs/guides/LINEAR_SETUP_GUIDE.md:24 recommends --context enableLinearIdentityVault=true with no note that compute_type=lambda-microvm hard-fails synth at 505 resources; today the only record is a test title (cdk/test/stacks/agent.test.ts:1721). Add it to the guide (+ mise //docs:sync) and ADR-016's scope table. Same pass: dismiss CodeQL docs: refactoring docs #41 / feat(slack): add Slack integration with @mention-based task submission #42 with reasons so the next reviewer can tell them from a regression, and replace the live deployment hostname d2ud1woydykuxp.cloudfront.net at cli/test/commands/linear.test.ts:294 with https://d111111abcdef8.cloudfront.net/ — this is a public sample repo, and it also removes what docs: refactoring docs #41 latched onto.

Explicitly fine as a fast-follow — including three I'd marked MAJOR, which I'm downgrading:

  • The un-latch not being env-gated (linear-oauth-resolver.ts:444) — it only runs on an already-latched row, so the surface is narrow.
  • The quadruplicated vault cache-key constants + contracts/constants.json parity test — a guardrail, and the four copies are currently consistent.
  • agent/src/config.py:135 (platform_client outside the try) — the same defect class as item 1, so take it if you're already there; the agent image pins botocore, which makes UnknownServiceError near-unreachable in practice.
  • All remaining nits: the :398 raw literal and reason typing, parseOauthSecret mode-keyed overloads, RegistryRow.revoked_at, resolveLinearVaultWorkloadName's .catch, the two stale --hosted references, linear-auth-health.ts:38, :308, :910, the comment archaeology / proportionality sweep, linear-webhook-processor.test.ts:1092's adjacency assertion, the per-test AgentStack synth at agent.test.ts:1595+, the linearVaultHostedReturnUrl doc, and the nested-type gap in synth-coverage.test.ts:158.

I'll approve as soon as the seven land. The engineering quality assessment in my prior review is unchanged and I'll repeat it here: the tri-state VaultTokenResult, the announce-before-latch with an independent revocation_announced_at claim, the installed_at-conditioned latch, and the UpdateItem-only registry grant are all correct and well argued in place.

Four correctness findings from review. Each turns a recoverable condition into a
wrong verdict, which is the class this PR exists to remove — so they are worth
fixing rather than noting.

**A transient vault error escaped the "never throws" contract.** `makeClient` sat
outside `resolveLinearTokenViaVault`'s `try`, so a constructor failure bypassed the
`unavailable` classification and left the function via a path
`resolveLinearOauthToken`'s Step 1b does not guard — turning a degradable condition
into a hard failure for every vault workspace. Moved inside. This is the remnant of
a blocker the reviewer withdrew after bundling each Lambda entry and finding `main`
already ships the same `require`; the withdrawal was right, and this two-line part
still stood.

**The un-latch was ungated where the latch is gated.** `defaultRevocationRecorder`
is gated on `LINEAR_REVOCATION_RECORDING` because five of the six minting Lambdas
hold read-only on the registry, but `clearWorkspaceRevocation` was called
unconditionally — so those five logged an AccessDenied at `error` on every event for
a workspace the resolver was serving correctly. Gated on `recordRevocation`, which is
present exactly where the write is; the row now stays latched until the write-capable
role re-probes, the same asymmetry the latch already has. A test pins that a
read-only role still serves the token and attempts no write.

**`setup` could silently demote a vault workspace to Secrets Manager.** `useVault`
ignored `alreadyOnVault`, which the upsert-error path two lines down uses correctly
("Only a FIRST onboarding falls back"). A stack whose last deploy omitted the vault
context, or a `--code` resume, took the direct-OAuth path and rewrote the row whole —
dropping `provider_name`/`vault_user_id` behind a green "Setup complete" and orphaning
the AgentCore provider. Now fails fast naming which of the two causes applies. The
message it printed was also a fabricated diagnosis: nothing probed regional
availability, only this stack's outputs, so it now says that instead.

**The CLI collapsed "could not ask the vault" into "grant revoked".** The minter
returned `null` for any exception and `linear-auth-health` mapped that to `revoked`
with the remedy `bgagent linear setup` — which per this PR's own comment can replace
the Linear installation and invalidate a working grant, rendered by `platform doctor`
as a hard fail. Now a tri-state mirroring the cdk-side `VaultTokenResult`:
`unavailable` maps to `unknown`, matching what the same module already does 30 lines
above when it cannot query the vault at all, and a latch is no longer overturned by a
probe that failed. The `nosemgrep` rationale claiming the distinction changes no
caller's action was falsified by the caller this PR added, and is gone.

Also: `platform_client` moved inside the `try` in `config.py` — `UnknownServiceError`
and `InvalidRegionError` are both `BotoCoreError` subclasses, so they are now caught by
the existing handler instead of aborting a task before the 👀 acknowledgement.

**One test claimed coverage it did not provide.** "the recorded reason distinguishes
Linear refusing from a vault inference" asserted only the condition expression, and
the harness never captured `ExpressionAttributeValues`, so the reason ternary was
unverified — collapsing it to a constant kept the suite green while breaking the
self-heal. The harness now captures the values and drives the vault source too; both
arms assert `:reason`, and the collapse fails.
… an unsupported combo

Review found several comments that had stopped being true — three of them describing
mechanisms this PR itself changed, which is the worst kind to leave: acting on them
reintroduces the bug they were written about.

- `linear-revocation-alert.ts` header still said the registry latch is the dedup key.
  It is not, and deliberately so: the caller announces BEFORE latching, keyed on an
  independent `revocation_announced_at` claim, because keying it off the latch made a
  failed publish permanently silent. Same class: `markWorkspaceRevoked`'s "that boolean
  is the dedup key" — no production caller consumes the return at all.
- `agent.ts` said the workload name matches "the LinearIdentityVault construct's fixed
  `abca_linear_oauth`". The name is stack-derived and passed INTO the construct, which
  has no default. Renaming it orphans every consent, so this was the worst comment in
  the file to have wrong.
- `linear-auth-health.ts` claimed the module "never consumes a refresh token"; supplying
  `verifyRefresh` rotates and persists one. Qualified as read-only by default.
- The "operator has still been told" note justified an `error` level by an announcement
  that has not happened on the no-topic path — which is the total-silence case, and the
  actual reason the level is right.
- Two references to a `bgagent linear setup --hosted` flag that does not exist.

Trimmed rather than added: the `LINEAR_REVOCATION_RECORDING` block was 19 lines for one
env var and `vaultMetadata`'s docstring 18. Both kept every decision a future reader
needs — including why granting the reconciler the write is refused — and dropped the
PR diary ("third occurrence in this change", "LIVE-CORRECTED"). Where a fact came from
a live run rather than reasoning, that is still stated, because it is the reason not to
"simplify" the code back.

The `vaultMetadata` structural guard no longer requires the spread on the immediately
next line — adjacency failed on a harmless key reorder, which trains people to edit the
guard. It now scans to the end of the enclosing literal, still catching the case the
behavioural test cannot: a new builder nobody exercised.

`resolveLinearVaultWorkloadName` no longer wraps `getStackOutput` in `.catch(() => null)`.
That helper already returns null for a stack that does not exist and rethrows auth
errors; swallowing them turned a DescribeStacks AccessDenied into the fallback name,
which no current stack uses now that the name is stack-scoped — so the operator got a
mint failure against an identity that was never provisioned.

**Vault + `compute_type=lambda-microvm` is now refused by name.** The combination
synthesizes 505 resources against a hard limit of 500 (MicroVM alone 496, the vault
alone 488). It was previously left to the resource counter, whose message is a per-type
census that never mentions either flag, so an operator could not tell what to change.
`AgentStack` now throws naming both, and the incompatibility is in the setup guide and
ADR-016's substrate notes instead of only a test title.

Test fixtures no longer carry a real deployment hostname
(`d2ud1woydykuxp.cloudfront.net` → the AWS documentation placeholder), which also
removes what CodeQL #41 latched onto.
… copies

Review's last MAJOR. The scopes and `customParameters` that form the vault's cache key
existed as four independent literals — the Lambda resolver, both CLI paths, and the
agent — held together by "keep in sync" comments. They agree today; I checked before
changing anything, so this is a guard, not a repair.

It is worth a guard because the failure is silent and the blast radius grew with #812.
AgentCore keys a cached grant by the WHOLE token request, `customParameters` included,
so a one-token divergence between any two copies turns every resolve into a cache miss.
Before #812 that degraded to Secrets Manager; now it is reported as `consent-required`,
and a `consent-required` with no Secrets-Manager fallback latches the row `revoked`. So
a typo in one of four files can take a healthy workspace offline until a human
re-consents.

`contracts/constants.json` already exists for exactly this (it holds the
`approval_gate_cap` triplication and the `microvm_platform_config` maps), so this adds a
`linear_vault` block and enforces it the two ways this repo already enforces such
things:

- `check:constants-sync` forbids `agent/src/config.py` from re-declaring the values as
  literals at all, so it must read the contract. Both new patterns are mutation-checked
  by a test in the gate's own suite.
- Parity tests assert the TypeScript copies equal the contract, one per side, alongside
  the existing `constants-parity` and `stored-oauth-token-parity` tests.

Two things found while wiring it, both of which would have made the guard useless:
`agent/src/config.py` was not in the gate's `PYTHON_CONSUMERS` list, so the patterns
matched nothing until it was added — verified by re-declaring a literal and watching the
gate go from silent to failing. And the gate's own test fixture enumerates the files it
copies, so it needed `config.py` too or the checker read a path the fixture had not
created.

The cdk-side parity test also asserts the contract is non-empty: a parity test over an
empty block passes while enforcing nothing.
@isadeks

isadeks commented Sep 2, 2026

Copy link
Copy Markdown
Contributor Author

Addressed at 6288d1c7 — three commits

Thank you for withdrawing B1 the way you did. Bundling each entry with CDK's own esbuild config and counting the emitted require is a better standard than the static import-chain reading I would have applied, and it found the thing that settles it: main already ships require("@aws-sdk/client-bedrock-agentcore") from fanout-task-events and github-webhook-processor under the identical externalization, so the module demonstrably resolves in nodejs24.x.

The two-line remnant was real and is fixed: makeClient now sits inside the try, so an SDK surprise degrades to { kind: 'unavailable' } and the Secrets-Manager fallback rather than escaping past the classification into a Step 1b that has no guard.

Every finding below I verified against the code before changing anything. None was a false positive — I looked, and the one thing worth reporting in that direction is that the four cache-key copies already agree, so that work is a guard rather than a repair, and the commit says so.

The four correctness MAJORs

setup stripping the vault binding. Confirmed: alreadyOnVault is used at :954 ("Only a FIRST onboarding falls back") but not at :928, so a missing stack output or a --code resume rewrote the row whole and dropped provider_name/vault_user_id. It now fails fast, naming which of the two causes applies. Your point about the fabricated diagnosis was right too — nothing probed regional availability, only this stack's outputs — so the message now says that instead of sending the operator to the AWS region table.

The CLI collapsing "vault unreachable" into "grant revoked". Fixed as you suggested: mintLinearTokenFromVault returns a tri-state mirroring the cdk-side VaultTokenResult, unavailable maps to unknown, and a latch is no longer overturned by a probe that merely failed. The two callers that genuinely only need a token now say so explicitly at the call site. The nosemgrep rationale claiming the distinction changes no caller's action was falsified by the caller this PR added — it is gone, not reworded.

The un-latch not env-gated like the latch. Gated on recordRevocation, which is present exactly where the registry write is, so the same permission governs both directions and no new env read is introduced. A test pins that a read-only role still serves the token and attempts no write.

The quadruplicated cache key. Now a linear_vault block in contracts/constants.json, enforced the two ways this repo already enforces such things: check:constants-sync forbids config.py re-declaring the literals, and parity tests assert each TypeScript copy equals the contract.

Two things surfaced while wiring that, both of which would have left the guard decorative. agent/src/config.py was not in the gate's PYTHON_CONSUMERS, so the new patterns matched nothing — found by re-declaring a literal and watching the gate stay silent. And the gate's own fixture enumerates the files it copies, so it needed config.py too, or the checker read a path the fixture never created.

Tests and docs

The mislabelled test. You were exactly right, including the consequence: the harness never captured ExpressionAttributeValues, so the reason ternary was unverified and collapsing it to a constant kept the suite green. The harness now captures the values and can drive the vault source, both arms assert :reason, and I confirmed the collapse you described now fails.

Vault + lambda-microvm. AgentStack refuses the combination by name at synth instead of leaving it to the resource counter, whose per-type census never mentions either flag. Documented in the setup guide and ADR-016's substrate notes rather than living only in a test title. The test that pinned the 500-limit symptom now pins the explicit refusal.

Nits

All fixed except two, noted below. Four comments had stopped being true, three of them describing mechanisms this PR changed — the alert header still claimed the latch is the dedup key, agent.ts still called the workload name fixed, linear-auth-health still claimed it never consumes a refresh token, and the error-level justification cited an announcement that has not happened on the no-topic path. Also fixed: the raw 'vault_consent_required' literal, a typed LinearRevocationReason, RegistryRow.revoked_at, the .catch that turned a DescribeStacks AccessDenied into a fallback name no current stack uses, both --hosted references, and the guard-location pointer.

Your proportionality point landed, and I applied it in both directions: the LINEAR_REVOCATION_RECORDING block went from 19 lines to 9 and vaultMetadata's docstring from 18 to 9, keeping every decision — including why granting the reconciler the write is refused — and dropping the archaeology. Where a fact came from a live run rather than reasoning that is still stated, since it is the reason not to "simplify" the code back.

The vaultMetadata structural guard no longer requires adjacency; it scans to the end of the enclosing literal, so a key reorder cannot fail it while a new unspread builder still does. Fixtures use AWS's documentation host now, and both CodeQL alerts are dismissed with reasons.

Deferred, deliberately: the parseOauthSecret mode-keyed overloads. It is a genuine improvement and there is no live defect behind it — the only 'without-grant' caller is webhook verification — so I would rather not reshape a parse path this PR already changed twice. And the commands/linear.ts split, which we agree is a follow-up.

Not fixed, and I would rather you decide: your adjacent finding about cli/src/linear-oauth.ts printing the raw token body was fixed here in 2ced095e, but cli/src/jira-oauth.ts:327 has the identical pattern. That file is not in this PR's diff, so I left it — say the word and it gets its own PR.

mise run build green at this HEAD: CDK 4454, CLI 903, agent 1782.

An audit of the comments this branch adds turned up several that assert the
opposite of what the code now does, plus one rendering defect the source hides.

Factually wrong, now fixed:

- The `markWorkspaceRevoked` call site claimed the revoked-marker is "OPT-IN, not
  defaulted" and that "no stack grants it write", eleven lines under the line that
  defaults it — and the write grant plus `LINEAR_REVOCATION_RECORDING` now ship in
  `linear-integration.ts`. The block also directly contradicted the two lines
  appended beneath it.
- Step 1b claimed "the vault NEVER blocks token resolution". A row latched on the
  vault inference returns after the re-probe, so Secrets Manager is never consulted.
- `resolveViaVault` carried two docblocks; the dead first one described a
  `string | null` return the type no longer has.
- The vault-token header and `workspaceUserId` both presented
  `linear-workspace-<orgId>` as the live subject. It is the pre-#809 fallback —
  fresh installs bind the slug-derived id recorded at consent time. That function
  is now named `legacyWorkspaceUserId`, so the one fact a reader needs is in the
  name instead of a paragraph above it; its test pinned the same wrong framing
  ("matches registry-table convention") and now says what the pin is actually for.
- Two docblocks sat on the wrong declaration: the `resolveLinearOauthToken`
  contract on `ResolvedLinearToken`, and the `VAULT_CONSENT_REVOCATION_REASON`
  description on the type above it.
- The locally-declared `AnnounceableRevocation` cited an import cycle. `import type`
  is erased and no `import/no-cycle` rule is configured; the real reason is that
  the alert module depends on nothing of the resolver's.

Stale: the cache-key literals still said "keep in sync with" their three peers.
That is now enforced by `contracts/constants.json` and the parity test, so they
point at the contract instead. A cross-reference to `workspaceUserId` "in
config.py" named a symbol that never existed there — the agent inlines the form.

De-duplicated: the announce-before-latch rationale appeared five times and the
`LINEAR_REVOCATION_RECORDING` rationale three; each is now stated once where the
mechanism lives, with pointers elsewhere. Dropped an intra-branch commit SHA and
the bug-history retellings that stop meaning anything after squash-merge. Every
comment recording a non-inferable fact is kept verbatim — AgentCore's
`customParameters` cache key, Linear's non-RFC error body, the `installed_at`
conditioning, the nosemgrep justifications.

The resolver drops from 40% comment lines to 38%, against a 39% house norm in
`cdk/src`; no unique fact was removed.

Fixed while rendering the copy: the operator alert printed the same
`bgagent linear setup` command twice on the vault path, and a
`.filter(line => line !== '')` stripped every intentional blank line, collapsing
the email into one block. Both were invisible in source and green under
`toContain`; the new tests pin the command count and the paragraph breaks, and
both were mutation-proven to fail against the old rendering.

Also strengthened the refresh test, which asserted PutSecretValue was *called*
but not what it wrote — persisting the pre-rotation bundle left it green, which
is the failure mode that makes a grant die on its first refresh.
…s missed

The first pass covered 12 files (1,595 of 2,685 added comment lines). This one
covers the remaining 34, and re-sweeps all 46 for the cross-cutting categories.

Wrong:

- `linear-identity-provisioning` credited "the return URL is mandatory" to spike
  finding F8. That is F7; F8 is "a hosted https return URL works identically".
  Both now cited, along with F9 and F11.
- `linear-vault-consent-page` pinned "The root `AgentStack` was at 486" — an
  undated reading of a number that moves every PR, so it is unfalsifiable prose
  that will eventually read as wrong. States the constraint instead.
- Its two onboarding bullets both named `bgagent linear setup`, making the
  distinction they draw invisible. They are two redirect legs of one command.

Stale — `vault-setup` and `--hosted` were removed when onboarding was consolidated,
but five references still used them as current:

- `agent.test.ts` claimed the published output is what lets `vault-setup --hosted`
  find the consent page. Neither the command nor the flag exists (`--hosted-consent-url`
  does).
- `cli/src/linear-vault.ts` twice cited "a second `vault-setup` run".
- `linear-pending-consent.test.ts` used `setup --hosted` in its file header and in a
  test name.

The negative assertions that check `vault-setup` does NOT appear in operator output
are correct and were left alone.

Duplication, and the one place it was worth fixing structurally: five parallel
builder signatures in `linear-webhook-processor.ts` each carried a byte-identical
six-line inline type for `resolved` under a byte-identical four-line comment — 50
lines restating one fact five times. Replaced by a named `BuilderResolvedToken`,
so the invariant the comment warned about (never narrow this per site, the callers
pass the whole resolver result) is now enforced by the type instead of by prose.

Narration: dropped "this PR" from `check-constants-sync.test.ts` and
`linear-oauth-resolver.test.ts`, and the fourth restatement of the recorded-vs-derived
subject rationale in `config.py` is now a pointer. The `fb1e007b` reference in
`ecs-agent-cluster.test.ts` is kept — that commit is on main, so it stays resolvable.

Checked and deliberately left: `config.py`'s "the vault never blocks a task" is true
there (it reads no registry row and has no latch, unlike the Lambda resolver, where
the same sentence was wrong and was corrected in the previous commit).
…-revocation-alerts

# Conflicts:
#	cdk/test/stacks/agent.test.ts
…-pr' into feat/812-credential-revocation-alerts

@scottschreckengaust scottschreckengaust left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM - consider a followup to address comments

@isadeks
isadeks added this pull request to the merge queue Sep 4, 2026
Merged via the queue into main with commit 12c9b63 Sep 4, 2026
8 checks passed
@isadeks
isadeks deleted the feat/809-linear-identity-vault-pr branch September 4, 2026 16:42
scottschreckengaust added a commit that referenced this pull request Sep 4, 2026
…base

Rebasing onto main (12c9b63) surfaced three collisions with work that
merged after this branch was authored. None were textual conflicts — git
merged the affected files cleanly and produced a build that failed.

1. `allowTestInvoke: false` on the DELETE integration.
   #854 stripped the API Gateway console test-invoke Lambda permissions to
   reclaim CloudFormation resources under the 500-resource ceiling, and
   added a guard asserting none are emitted. This route predates that
   convention, so it re-introduced one and failed the guard 6x (once per
   compute_type x enableToolGateway variant). The two sibling routes in
   this same construct already pass the option; now all three agree.

2. Attributed-Lambda count 46 -> 47.
   The original a19f16d bumped 45 -> 46 for RemoveWorkspaceFn, but main
   independently reached 46, so `git rebase` dropped the commit as "patch
   contents already upstream" — identical text, different reason. The
   textual change survived; the intent did not. With this branch's extra
   Lambda the correct value is 47.

3. Solution user agent on the new handler's SDK clients.
   linear-remove-workspace.ts constructed `new DynamoDBClient({})` and
   `new SecretsManagerClient({})` directly, which drops the solution user
   agent (#319). Now built through makeDocClient()/makeClient(), matching
   linear-link.ts and linear-webhook.ts.

Also resolved the LINEAR_SETUP_GUIDE.md conflict from #831: kept its new
"Vault-managed workspaces" section and dropped the duplicated webhook/
uninstall sentence, which this branch had already relocated into the
parent "Removing a workspace" section. Starlight mirror regenerated.

Refs #306
isadeks added a commit that referenced this pull request Sep 8, 2026
Conflicts with #831 (Linear OAuth Token Vault), which touched the same three files.
All three were additive on both sides; resolved as unions.

- `cdk/src/constructs/ecs-agent-cluster.ts` — imports. This branch's model constants
  plus main's `LinearIdentityVault`.

- `cdk/src/stacks/agent.ts` — the lambda-microvm `platform_config` block. Both sides
  add fields, so both are kept, but NOT verbatim: main's side calls
  `haikuInferenceProfileId(bedrockGeoRegion)`, and that helper no longer exists here —
  it was removed when the two duplicate haiku-id paths were collapsed into one. Taking
  main's line as written would not compile. Resolved to
  `inferenceProfileId(bedrockGeoRegion, PLATFORM_DEFAULT_AUX_MODEL_ID)`, which is the
  same value through the surviving helper, and the vault fields are kept unchanged.

- `cli/src/platform-doctor.ts` — three hunks: the `linear-auth-health` import, and the
  `Promise.all` destructuring plus its call list. The last two must stay positionally
  aligned, so both were extended in the same order; verified all ten names now line up
  with their outputs.

Also fixed a `{@link haikuInferenceProfileId}` in `bedrock-models.ts` left dangling by
that same helper removal — the merge is what surfaced it, since main's live call site
was the only other reference.

`yarn.lock` moves with main (+42, `@aws-sdk/client-sns` for #831's revocation alerts).

Checked for semantic drift rather than trusting a clean textual merge: the restored
"creates exactly 21 DynamoDB tables" guard is byte-identical to main's at the same line,
so #831 added no table and the merge did not take a stale side of it.

Suites after merge: 4499 CDK, 948 CLI, 1782 agent — `mise run build` clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

adapters Third-party integrations: Linear, Slack, GitHub App, notification/deploy providers enhancement New feature or request security Cedar/HITL, IAM least-privilege, secrets, PII/DLP, guardrails, supply-chain/CVE

Projects

None yet

5 participants