Skip to content

Commit 6b73969

Browse files
authored
Merge branch 'pr/ecs-test-hygiene' into fix/615-ecs-scoped-session-hang
2 parents 518b821 + 5ef12a0 commit 6b73969

7 files changed

Lines changed: 575 additions & 27 deletions

File tree

agent/tests/test_shell.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,44 @@ def test_benign_zero_errors_line_not_surfaced_as_failure(self):
268268
blob = "\n".join(text for _, text in logs)
269269
assert "ok 19" in blob
270270

271+
def test_marker_line_with_noise_term_is_filtered_by_allowlist(self):
272+
# N3: the LOAD-BEARING allowlist case. A line that hits a real marker
273+
# (`error:`) AND a noise term (`0 errors`) must be filtered OUT of the
274+
# surfaced set. Placed in the MIDDLE (before the tail window) so it is
275+
# only reachable via the marker scan — if _FAILURE_LINE_NOISE were
276+
# deleted, this line WOULD be surfaced and the assertion would fail.
277+
# A genuine failure line follows so the scan still produces a match set.
278+
noisy = "error: eslint produced 0 errors after autofix retry" # marker + noise
279+
real = "[//cdk:test] FAIL test/handlers/bar.test.ts — boom"
280+
# 30 trailing passing lines push both lines above out of the tail window,
281+
# so `noisy` is only reachable via the marker scan (where the allowlist runs).
282+
stdout = f"{noisy}\n{real}\n" + "\n".join(f"[//agent:test] passing {i}" for i in range(30))
283+
proc = self._completed(1, stdout=stdout, stderr="")
284+
logs = self._run_capturing_logs(proc)
285+
blob = "\n".join(text for _, text in logs)
286+
assert "FAIL test/handlers/bar.test.ts" in blob # real failure surfaced
287+
assert "0 errors after autofix retry" not in blob # noisy marker filtered by allowlist
288+
289+
def test_surfaced_failure_lines_are_capped_and_truncation_marked(self):
290+
# N4: a genuinely huge red run (more than _MAX_SURFACED_FAILURE_LINES
291+
# marker lines) must be capped, with an explicit truncation breadcrumb —
292+
# so it can't flood CloudWatch. Exercises the cap branch the other tests
293+
# never reach.
294+
from shell import _MAX_SURFACED_FAILURE_LINES
295+
296+
n = _MAX_SURFACED_FAILURE_LINES + 10
297+
stdout = "\n".join(f"FAIL test/case_{i}.test.ts — assertion {i}" for i in range(n))
298+
proc = self._completed(1, stdout=stdout, stderr="")
299+
logs = self._run_capturing_logs(proc)
300+
blob = "\n".join(text for _, text in logs)
301+
# The marker-scan hit the cap and emitted the truncation breadcrumb.
302+
assert "… (more failure lines truncated)" in blob
303+
# The breadcrumb appears within the surfaced set BEFORE the tail divider —
304+
# i.e. the matched-line scan was capped, not the whole output dumped.
305+
head = blob.split("--- (trailing output) ---")[0]
306+
head_cases = sum(1 for i in range(n) if f"case_{i}.test.ts" in head)
307+
assert head_cases <= _MAX_SURFACED_FAILURE_LINES
308+
271309
def test_stdout_is_redacted(self):
272310
proc = self._completed(1, stdout="error: pushing with ghp_supersecrettoken123", stderr="")
273311
logs = self._run_capturing_logs(proc)

cdk/src/constructs/ecs-agent-cluster.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -184,10 +184,13 @@ export class EcsAgentCluster extends Construct {
184184
LOG_GROUP_NAME: logGroup.logGroupName,
185185
GITHUB_TOKEN_SECRET_ARN: props.githubTokenSecret.secretArn,
186186
// Heavy CI-parity builds on this big-box substrate legitimately run
187-
// longer than the 1800s default (ABCA's own `mise run build` is
188-
// ~50 min cold). Raise the post-agent build-verify cap so a slow-but-
189-
// healthy build isn't mis-reported as a timeout (see post_hooks.py
190-
// BUILD_VERIFY_TIMEOUT_S). ECS-only: AgentCore repos keep the default.
187+
// long (ABCA's own `mise run build` is ~50 min cold), so set a generous
188+
// post-agent build-verify cap here for the ECS-only path. NOTE: the
189+
// consuming side (verify_build/verify_lint reading BUILD_VERIFY_TIMEOUT_S
190+
// and passing it as the subprocess timeout) ships with the ECS-substrate
191+
// work; on `main` today the verify subprocess still uses run_cmd's
192+
// default cap, so this env is provisioned ahead of the wiring rather
193+
// than currently effective. ECS-only: AgentCore repos don't set it.
191194
BUILD_VERIFY_TIMEOUT_S: '3600',
192195
...(props.memoryId && { MEMORY_ID: props.memoryId }),
193196
// #502: the payload bucket name so the orchestrator-issued

cdk/src/handlers/shared/error-classifier.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -434,21 +434,29 @@ const PATTERNS: readonly ErrorPattern[] = [
434434

435435
// --- Timeout ---
436436
{
437-
// The build/verify command shelled out and was KILLED at the wall-clock cap
438-
// (Python subprocess `TimeoutExpired … timed out after N seconds`). Live-caught
439-
// on ABCA-667: the fork's full `mise run build` (~2800 tests) exceeded the
440-
// 600s default and surfaced as a bare "Unexpected error". This is NOT a code
441-
// failure — the build didn't fail, it ran too long — so name it precisely and
442-
// point at the timeout, not the diff. On a big repo the fix is a higher
443-
// BUILD_VERIFY_TIMEOUT_S (or the ECS build box), which an admin sets — but a
444-
// one-off may just be slow, so it's a user-actionable "retry / raise the cap".
445-
pattern: /TimeoutExpired.*timed out after \d+ ?s(econds)?|Command .*build.* timed out/i,
437+
// An ``agent/`` subprocess (``run_cmd``) hit its wall-clock cap and Python
438+
// raised an uncaught ``TimeoutExpired … timed out after N seconds``, which
439+
// otherwise surfaces as a bare "Unexpected error". This is NOT a code
440+
// failure — the command didn't fail, it ran too long — so name it precisely
441+
// and point at "slow, retry", not the diff. NOTE: this matches only
442+
// UNCAUGHT ``run_cmd`` timeouts (clone/setup/etc.); the post-agent build/lint
443+
// VERIFY path catches ``TimeoutExpired`` itself and returns a plain build
444+
// failure, so it does not reach here. The remedy is deliberately generic
445+
// ("retry / it may be slow") rather than naming a specific env knob — on
446+
// ``main`` the verify timeout is not operator-tunable, so promising a lever
447+
// here would misdirect (the tunable ``BUILD_VERIFY_TIMEOUT_S`` + larger ECS
448+
// build compute live on the ECS-substrate track, not this branch).
449+
pattern: /TimeoutExpired.*timed out after \d+(?:\.\d+)? ?s(econds)?|Command .*build.* timed out/i,
446450
classification: {
447451
category: ErrorCategory.TIMEOUT,
448452
title: 'Build/tests didn\'t finish in time (timed out)',
449453
description: 'The configured build/verify command was still running when it hit the time limit and was stopped — it did not fail, it ran too long.',
450-
remedy: 'This is usually a slow build, not broken code. Retry (a one-off may just be slow); if this repo\'s build is legitimately long, an admin can raise BUILD_VERIFY_TIMEOUT_S or move it to the larger ECS build compute.',
454+
remedy: 'This is usually a slow build, not broken code — retry, since a one-off may just have been slow. If a repo\'s build is legitimately long, its build environment likely needs more time or capacity; contact your ABCA admin.',
451455
retryable: true,
456+
// Intentionally USER (no auto-retry): re-running an unchanged slow build
457+
// just times out again, so this must NOT be TRANSIENT — a human decides
458+
// whether to retry or right-size the build. Do not "fix" this to TRANSIENT.
459+
errorClass: ErrorClass.USER,
452460
},
453461
},
454462
{

cli/src/commands/linear.ts

Lines changed: 82 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,8 @@ import {
4343
generatePkce,
4444
LINEAR_OAUTH_SECRET_PREFIX,
4545
linearOauthSecretName,
46+
readExistingWebhookSecret,
47+
resolveWebhookSecretAction,
4648
StoredLinearOauthToken,
4749
} from '../linear-oauth';
4850
import { 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,

cli/src/linear-oauth.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -293,3 +293,88 @@ function isLinearTokenResponse(value: unknown): value is LinearTokenResponse {
293293
export function computeExpiresAt(expiresInSeconds: number, now: Date = new Date()): string {
294294
return new Date(now.getTime() + expiresInSeconds * 1000).toISOString();
295295
}
296+
297+
/** What `bgagent linear setup` should do about the webhook signing secret. */
298+
export type WebhookSecretAction =
299+
/** This workspace already has its own signing secret — keep it (re-run). */
300+
| { readonly kind: 'preserve'; readonly secret: string }
301+
/** No per-workspace secret; mirror the stack-wide one (safe for the first
302+
* workspace, ambiguous for an additional one — caller should warn). */
303+
| { readonly kind: 'mirror-stackwide' }
304+
/** No secret anywhere — prompt the operator for it (first install). */
305+
| { readonly kind: 'prompt' };
306+
307+
/**
308+
* Decide which webhook signing secret `setup` should use, WITHOUT clobbering a
309+
* working per-workspace secret.
310+
*
311+
* The bug this fixes: re-running `setup` on an already-installed workspace used
312+
* to lift the *stack-wide* signing secret into the per-workspace bundle. That's
313+
* correct only for the FIRST workspace — the stack-wide secret belongs to
314+
* whichever workspace installed first, so for any additional workspace it
315+
* overwrites the correct per-workspace secret with the wrong one, silently
316+
* breaking signature verification (webhook 401 "Invalid signature").
317+
*
318+
* Rule: an existing valid per-workspace secret always wins (rotation is
319+
* `update-webhook-secret`'s job); else mirror the stack-wide secret if present;
320+
* else prompt. Pure — the caller supplies what it read from Secrets Manager.
321+
*
322+
* @param existingPerWorkspaceSecret the `webhook_signing_secret` on this
323+
* workspace's OAuth bundle BEFORE the setup rewrite (undefined if none).
324+
* @param stackWideConfigured whether the stack-wide fallback secret is set.
325+
*/
326+
export function resolveWebhookSecretAction(
327+
existingPerWorkspaceSecret: string | undefined,
328+
stackWideConfigured: boolean,
329+
): WebhookSecretAction {
330+
if (existingPerWorkspaceSecret?.startsWith('lin_wh_')) {
331+
return { kind: 'preserve', secret: existingPerWorkspaceSecret };
332+
}
333+
if (stackWideConfigured) {
334+
return { kind: 'mirror-stackwide' };
335+
}
336+
return { kind: 'prompt' };
337+
}
338+
339+
/**
340+
* Read this workspace's EXISTING `webhook_signing_secret` before `setup`
341+
* overwrites the OAuth bundle — FAIL CLOSED (#612 review B1).
342+
*
343+
* `fetchSecretString` fetches the prior per-workspace OAuth `SecretString`
344+
* (typically a `SecretsManagerClient.send(GetSecretValueCommand)` wrapper). The
345+
* value fed to {@link resolveWebhookSecretAction} decides whether the existing
346+
* secret is preserved or the stack-wide one is mirrored over it — so a wrong
347+
* `undefined` here re-arms the #611 clobber. Therefore:
348+
*
349+
* • secret missing (`ResourceNotFoundException`) → genuine first install,
350+
* return undefined (nothing to preserve).
351+
* • secret present with a valid `lin_wh_…` `webhook_signing_secret` → return it.
352+
* • secret present but no/malformed secret → return undefined (nothing valid
353+
* to preserve; the caller mirrors/prompts).
354+
* • ANY OTHER error (AccessDenied, KMSAccessDenied, Throttling, network, or a
355+
* corrupt-JSON bundle) → THROW. Swallowing it would leave undefined and
356+
* silently clobber a working secret behind a green "Setup complete".
357+
*
358+
* `isNotFound` classifies the fetch error; callers pass an
359+
* `name === 'ResourceNotFoundException'` check. On a throw the caller re-raises
360+
* a CliError with an IAM/KMS hint (mirroring `isWebhookSecretConfigured`).
361+
*/
362+
export async function readExistingWebhookSecret(
363+
fetchSecretString: () => Promise<string | undefined>,
364+
isNotFound: (err: unknown) => boolean,
365+
): Promise<string | undefined> {
366+
let raw: string | undefined;
367+
try {
368+
raw = await fetchSecretString();
369+
} catch (err) {
370+
if (isNotFound(err)) return undefined; // genuine first install
371+
throw err; // fail closed — caller wraps with an actionable CliError
372+
}
373+
if (!raw) return undefined;
374+
// A corrupt-but-present bundle must surface (JSON.parse throws → propagates),
375+
// NOT silently become "nothing to preserve" → mirror-stackwide.
376+
const bundle = JSON.parse(raw) as Partial<StoredLinearOauthToken>;
377+
return bundle.webhook_signing_secret?.startsWith('lin_wh_')
378+
? bundle.webhook_signing_secret
379+
: undefined;
380+
}

0 commit comments

Comments
 (0)