@@ -43,6 +43,8 @@ import {
4343 generatePkce ,
4444 LINEAR_OAUTH_SECRET_PREFIX ,
4545 linearOauthSecretName ,
46+ readExistingWebhookSecret ,
47+ resolveWebhookSecretAction ,
4648 StoredLinearOauthToken ,
4749} from '../linear-oauth' ;
4850import { awaitOauthCallback , CALLBACK_URL } from '../oauth-callback-server' ;
@@ -601,6 +603,42 @@ export function makeLinearCommand(): Command {
601603 process . stdout . write ( ' → Storing OAuth token...' ) ;
602604 const sm = new SecretsManagerClient ( { region } ) ;
603605 const now = new Date ( ) . toISOString ( ) ;
606+ // Preserve any EXISTING per-workspace webhook signing secret before the
607+ // OAuth overwrite below. Re-running `setup` on an already-installed
608+ // workspace must NOT clobber its working signing secret with the
609+ // stack-wide fallback (which belongs to whichever workspace installed
610+ // first) — that silently breaks signature verification (401 "Invalid
611+ // signature") for every workspace after the first in a multi-workspace
612+ // deployment. Rotation stays the job of `update-webhook-secret`.
613+ // Fail CLOSED (#612 review B1): read this workspace's existing signing
614+ // secret before the overwrite. Only ResourceNotFoundException is a clean
615+ // first-install; any other SM error (AccessDenied, KMSAccessDenied,
616+ // Throttling, network) — or a corrupt bundle — must surface, NOT default
617+ // to undefined (which would mirror the stack-wide secret over a working
618+ // per-workspace one → the #611 401 clobber, silently, behind a green
619+ // "Setup complete"). Extracted to readExistingWebhookSecret + unit-tested.
620+ let existingWebhookSecret : string | undefined ;
621+ try {
622+ existingWebhookSecret = await readExistingWebhookSecret (
623+ async ( ) => {
624+ const prior = await sm . send ( new GetSecretValueCommand ( { SecretId : linearOauthSecretName ( slug ) } ) ) ;
625+ return prior . SecretString ;
626+ } ,
627+ ( err ) => ( err as { name ?: string } ) . name === 'ResourceNotFoundException' ,
628+ ) ;
629+ } catch ( err ) {
630+ const errorName = ( err as { name ?: string } ) . name ;
631+ const message = err instanceof Error ? err . message : String ( err ) ;
632+ throw new CliError (
633+ `Failed to read the existing Linear webhook secret for '${ slug } ' before re-write: `
634+ + `${ errorName ?? 'Error' } : ${ message } . Refusing to proceed — continuing could clobber `
635+ + 'this workspace\'s webhook signing secret with the stack-wide value (a 401 on every '
636+ + 'delivery). Likely an IAM/KMS gap: confirm your CLI principal has '
637+ + '`secretsmanager:GetSecretValue` (and kms:Decrypt if a CMK) on '
638+ + `${ linearOauthSecretName ( slug ) } , or run \`bgagent linear update-webhook-secret ${ slug } \` `
639+ + 'after fixing access.' ,
640+ ) ;
641+ }
604642 const stored : StoredLinearOauthToken = {
605643 access_token : tokenResponse . access_token ,
606644 refresh_token : tokenResponse . refresh_token ?? '' ,
@@ -615,6 +653,14 @@ export function makeLinearCommand(): Command {
615653 installed_at : now ,
616654 updated_at : now ,
617655 installed_by_platform_user_id : cognitoSub ,
656+ // Fold the preserved secret into the INITIAL bundle (#612 review N2):
657+ // the OAuth-secret write below then lands the correct bundle in ONE
658+ // PutSecretValue, and the preserve path skips the later re-write. This
659+ // also closes a narrow window — if any fallible step between the two
660+ // writes threw, the bundle was left persisted WITHOUT the secret (401
661+ // until update-webhook-secret). Only set for a real `lin_wh_` value;
662+ // mirror-stackwide / prompt cases add it in the mirror-back below.
663+ ...( existingWebhookSecret ? { webhook_signing_secret : existingWebhookSecret } : { } ) ,
618664 } ;
619665 if ( ! stored . refresh_token ) {
620666 throw new CliError (
@@ -676,19 +722,40 @@ export function makeLinearCommand(): Command {
676722 // without re-onboarding. Multi-workspace installs need each
677723 // workspace to own its own per-workspace signing secret — only
678724 // the FIRST install can populate the stack-wide one usefully.
679- // If stack-wide is already populated, this is either a re-run
680- // of setup on the SAME workspace or the FIRST workspace of a
681- // future multi-workspace install. Either way the stored value
682- // is this workspace's signing secret — lift it into the
683- // per-workspace bundle without prompting (auto-migration to
684- // the new shape). Rotation is not setup's job: use
685- // `bgagent linear update-webhook-secret <slug>` to rotate the
686- // signing secret without re-running OAuth.
725+ //
726+ // resolveWebhookSecretAction picks one of three outcomes from
727+ // (existingWebhookSecret, stackWideAlreadyConfigured):
728+ // • preserve — this workspace ALREADY has its own `lin_wh_`
729+ // secret (a re-run). Keep it; do NOT overwrite from
730+ // the stack-wide fallback, which is a DIFFERENT
731+ // workspace's secret once >1 is installed. This is
732+ // the #611 clobber fix — the stack-wide value is NOT
733+ // necessarily this workspace's.
734+ // • mirror-stackwide — no per-workspace secret yet but stack-wide is
735+ // set: mirror it (correct for the first/only
736+ // workspace; warn for an additional one).
737+ // • prompt — nothing to go on: ask for the signing secret.
738+ // Rotation is not setup's job: use
739+ // `bgagent linear update-webhook-secret <slug>` to rotate.
687740 const stackWideAlreadyConfigured = await isWebhookSecretConfigured ( sm , webhookSecretArn ! ) ;
688741 let webhookSigningSecret : string | undefined ;
689742
690- if ( stackWideAlreadyConfigured ) {
691- console . log ( ' ✓ Webhook signing secret already configured stack-wide (mirroring to per-workspace)' ) ;
743+ const secretAction = resolveWebhookSecretAction ( existingWebhookSecret , stackWideAlreadyConfigured ) ;
744+ if ( secretAction . kind === 'preserve' ) {
745+ // This workspace already had its own signing secret — keep it. Do NOT
746+ // overwrite from the stack-wide fallback (a DIFFERENT workspace's
747+ // secret once >1 is installed). Re-run case; rotation is
748+ // `update-webhook-secret`'s job, not setup's.
749+ console . log ( ' ✓ Preserving this workspace\'s existing webhook signing secret (re-run — not overwriting)' ) ;
750+ webhookSigningSecret = secretAction . secret ;
751+ } else if ( secretAction . kind === 'mirror-stackwide' ) {
752+ // No per-workspace secret yet, but the stack-wide one is set. Safe to
753+ // mirror ONLY when this is the first/only workspace — for a genuinely
754+ // new ADDITIONAL workspace the stack-wide secret is the wrong one, so
755+ // warn that the operator should verify (or run `update-webhook-secret`).
756+ console . log ( ' ✓ No per-workspace secret yet; mirroring the stack-wide signing secret' ) ;
757+ console . log ( ' (if this is an ADDITIONAL workspace, its Linear webhook secret differs —' ) ;
758+ console . log ( ` run \`bgagent linear update-webhook-secret ${ slug } \` with this workspace's secret.)` ) ;
692759 try {
693760 const value = await sm . send ( new GetSecretValueCommand ( { SecretId : webhookSecretArn ! } ) ) ;
694761 if ( value . SecretString && value . SecretString . startsWith ( 'lin_wh_' ) ) {
@@ -725,9 +792,11 @@ export function makeLinearCommand(): Command {
725792 webhookSigningSecret = webhookSecret ;
726793 }
727794
728- // Mirror into the per-workspace OAuth secret so the receiver can
729- // look it up by orgId. Re-upsert with the merged payload.
730- if ( webhookSigningSecret ) {
795+ // Mirror into the per-workspace OAuth secret so the receiver can look it
796+ // up by orgId. In the PRESERVE case the secret is already in `stored`
797+ // (folded above) and was written by the OAuth-secret upsert — no re-write
798+ // needed. Only mirror-stackwide / prompt produce a NEW secret to persist.
799+ if ( webhookSigningSecret && webhookSigningSecret !== stored . webhook_signing_secret ) {
731800 const merged : StoredLinearOauthToken = {
732801 ...stored ,
733802 webhook_signing_secret : webhookSigningSecret ,
0 commit comments