Skip to content

Commit c5ff3ec

Browse files
authored
Merge branch 'main' into feat/257-semgrep-silent-success-masking
2 parents b03f0f2 + af33bc0 commit c5ff3ec

9 files changed

Lines changed: 1034 additions & 13 deletions

File tree

cdk/src/handlers/shared/agentcore-browser.ts

Lines changed: 53 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,14 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
262262
});
263263
}
264264

265+
// Track the main-document HTTP response so we can fail fast on 4xx/5xx
266+
// (404 / 503 / auth wall pages) instead of capturing what looks like the
267+
// app but isn't. Captured in a Network.responseReceived listener below;
268+
// checked after Page.loadEventFired but before Page.captureScreenshot.
269+
// (Auth walls that return 200 are out of scope — see issue #287.)
270+
let mainDocumentStatus: number | null = null;
271+
let mainDocumentFrameId: string | null = null;
272+
265273
try {
266274
// 1. List existing targets, find the default about:blank page.
267275
const targetsResp = await cdpSend('Target.getTargets');
@@ -281,9 +289,37 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
281289
throw new Error('Target.attachToTarget did not return a sessionId');
282290
}
283291

284-
// 3. Enable Page domain so we get the `Page.loadEventFired` event
285-
// we wait on below.
292+
// 3. Enable Page + Network so we get the `Page.loadEventFired` event
293+
// we wait on below AND the main-document response status. Network
294+
// has to be enabled BEFORE Page.navigate, or the response event
295+
// fires before our listener is wired and we miss the status.
286296
await cdpSend('Page.enable', {}, pageSessionId);
297+
await cdpSend('Network.enable', {}, pageSessionId);
298+
299+
// Tap the raw message stream for Network.responseReceived events —
300+
// we want a multi-fire listener (Document responses can appear for
301+
// redirect chains), not the one-shot waiter pattern that
302+
// eventWaiters / waitForEvent use. Records the latest matching
303+
// status; the post-load check below acts on whatever was captured.
304+
ws.on('message', (raw: RawData) => {
305+
let msg: CdpMessage;
306+
try {
307+
msg = JSON.parse(raw.toString()) as CdpMessage;
308+
} catch {
309+
return;
310+
}
311+
if (msg.method !== 'Network.responseReceived') return;
312+
const params = msg.params as
313+
| { type?: string; frameId?: string; response?: { status?: number } }
314+
| undefined;
315+
// CDP's `Network.responseReceived` fires for every resource (HTML,
316+
// JS, CSS, images, XHR, …). Only the type==='Document' event for
317+
// the navigated frame is the main-document response we care about.
318+
if (!params || params.type !== 'Document') return;
319+
if (mainDocumentFrameId && params.frameId !== mainDocumentFrameId) return;
320+
const status = params.response?.status;
321+
if (typeof status === 'number') mainDocumentStatus = status;
322+
});
287323

288324
// 4. Navigate. The response includes a `frameId`; we wait on the
289325
// `Page.loadEventFired` event below (more reliable than
@@ -294,6 +330,7 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
294330
if (navError) {
295331
throw new Error(`Page.navigate failed: ${navError}`);
296332
}
333+
mainDocumentFrameId = (navResp.result?.frameId as string | undefined) ?? null;
297334

298335
// 5. Wait for the page load event. SPA-style apps may continue
299336
// fetching after this fires, so add a 2s settle wait. For
@@ -302,7 +339,20 @@ async function runCdpScreenshot(wssUrl: string, url: string, timeoutMs: number):
302339
await waitForEvent('Page.loadEventFired');
303340
await new Promise((r) => setTimeout(r, 2000));
304341

305-
// 6. Take the screenshot.
342+
// 6. Reject non-2xx main-document statuses before screenshotting.
343+
// A 404 / 503 / auth wall renders a "successful" page from CDP's
344+
// perspective; the user sees a confidently-wrong screenshot of an
345+
// error page posted as the deploy preview. Throw → processor's
346+
// catch logs and skips the PR/Linear comment cleanly.
347+
// If we never captured a status (Network.responseReceived was
348+
// queued but predicate didn't match — e.g. a redirect chain that
349+
// doesn't expose the final frame), fall through and capture
350+
// optimistically; that's the pre-#287 behaviour.
351+
if (mainDocumentStatus !== null && (mainDocumentStatus < 200 || mainDocumentStatus >= 300)) {
352+
throw new Error(`Preview URL returned HTTP ${mainDocumentStatus}; skipping screenshot`);
353+
}
354+
355+
// 7. Take the screenshot.
306356
const shotResp = await cdpSend('Page.captureScreenshot', {
307357
format: 'png',
308358
captureBeyondViewport: true,

cdk/src/handlers/shared/linear-issue-lookup.ts

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,17 @@ query IssueByIdentifier($identifier: String!) {
7373
`.trim();
7474

7575
/**
76-
* Look up a Linear issue by identifier (e.g. `ABCA-42`) by iterating
77-
* over every active workspace in the registry until one returns a
78-
* match. Returns the first hit.
76+
* Look up a Linear issue by identifier (e.g. `ABCA-42`).
7977
*
80-
* For v1 this scan is cheap — typical deployments have 1-2 workspaces.
81-
* If a stack ever onboards many workspaces sharing identifier prefixes,
82-
* a followup can store team_key prefixes on the registry row and route
83-
* directly. Until then, linear-time iteration is fine.
78+
* Routing strategy:
79+
* 1. Scan active workspaces (one round-trip — typical stacks have 1–2).
80+
* 2. If any row's `team_keys` contains the identifier's team key (`ABCA`),
81+
* query that workspace directly and return on hit.
82+
* 3. Otherwise fall back to iterating every active workspace until one
83+
* returns a match. This handles legacy rows missing `team_keys` (the
84+
* column was added in #96 and back-fills only on next `setup` /
85+
* `add-workspace` re-run) and the rare case where a team was added in
86+
* Linear after the workspace was registered.
8487
*
8588
* @param identifier `ABCA-42`-style Linear issue identifier
8689
* @param registryTableName name of LinearWorkspaceRegistryTable
@@ -90,7 +93,11 @@ export async function findLinearIssueByIdentifier(
9093
identifier: string,
9194
registryTableName: string,
9295
): Promise<LinearIssueLocation | null> {
93-
let active: Array<{ linear_workspace_id: string; workspace_slug: string }> = [];
96+
let active: Array<{
97+
linear_workspace_id: string;
98+
workspace_slug: string;
99+
team_keys: string[] | null;
100+
}> = [];
94101
try {
95102
const scanResp = await ddb.send(new ScanCommand({
96103
TableName: registryTableName,
@@ -101,6 +108,7 @@ export async function findLinearIssueByIdentifier(
101108
active = (scanResp.Items ?? []).map((item) => ({
102109
linear_workspace_id: item.linear_workspace_id as string,
103110
workspace_slug: item.workspace_slug as string,
111+
team_keys: Array.isArray(item.team_keys) ? (item.team_keys as string[]) : null,
104112
}));
105113
} catch (err) {
106114
logger.warn('Linear issue lookup: failed to scan workspace registry', {
@@ -114,7 +122,33 @@ export async function findLinearIssueByIdentifier(
114122
return null;
115123
}
116124

125+
// Identifier prefix is the part before the first dash (`ABCA-42` → `ABCA`).
126+
// Compare uppercase since Linear team keys are upper-case but inbound text
127+
// (PR titles, branch names) is mixed-case.
128+
const teamKey = identifier.split('-', 1)[0]?.toUpperCase();
129+
const prefixMatch = teamKey
130+
? active.find((ws) => ws.team_keys?.some((k) => k.toUpperCase() === teamKey))
131+
: undefined;
132+
133+
// Try the prefix-matched workspace first.
134+
if (prefixMatch) {
135+
const resolved = await resolveLinearOauthToken(prefixMatch.linear_workspace_id, registryTableName);
136+
if (resolved) {
137+
const found = await queryIssueByIdentifier(resolved.accessToken, identifier);
138+
if (found) {
139+
return {
140+
issueId: found,
141+
linearWorkspaceId: prefixMatch.linear_workspace_id,
142+
workspaceSlug: prefixMatch.workspace_slug,
143+
};
144+
}
145+
}
146+
}
147+
148+
// Fallback: iterate workspaces NOT already tried via prefix-match.
149+
// Covers legacy rows without `team_keys` and post-registration team adds.
117150
for (const ws of active) {
151+
if (prefixMatch && ws.linear_workspace_id === prefixMatch.linear_workspace_id) continue;
118152
const resolved = await resolveLinearOauthToken(ws.linear_workspace_id, registryTableName);
119153
if (!resolved) continue;
120154

0 commit comments

Comments
 (0)