Skip to content

Commit 40ab352

Browse files
committed
fix: harden routed preview recovery boundaries
1 parent 6445ee1 commit 40ab352

8 files changed

Lines changed: 311 additions & 76 deletions

File tree

.github/workflows/agent-task-contracts.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ jobs:
9898
- run: npm run test:trusted-apply-artifact-channel
9999
- run: npm run test:runtime-command-artifact-bounds
100100
- run: npm run test:redaction
101+
- run: npm run test:browser-preview-routing
101102
- run: npm run test:production-boundary-enforcement
102103
- run: npm run test:runtime-tool-policy
103104

packages/runtime-playground/src/browser-metrics.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -527,7 +527,7 @@ export async function serializeBrowserFinishedRequest(request: Request, timestam
527527
if (!response) {
528528
return {
529529
type: "response",
530-
url: request.url(),
530+
url: redactBrowserNetworkUrl(request.url()),
531531
method: request.method(),
532532
resourceType: request.resourceType(),
533533
timestamp,
@@ -545,7 +545,7 @@ export async function serializeBrowserResponse(response: Response, timestamp = n
545545
const responseTextPreview = await browserDocument5xxResponsePreview(response)
546546
return {
547547
type: "response",
548-
url: response.url(),
548+
url: redactBrowserNetworkUrl(response.url()),
549549
method: request.method(),
550550
resourceType: request.resourceType(),
551551
status: response.status(),
@@ -596,17 +596,22 @@ function redactBrowserResponseText(body: string): string {
596596
}
597597

598598
export function serializeBrowserRequestFailure(request: Request, timestamp = now()): BrowserProbeNetworkRecord {
599+
const failure = request.failure()
599600
return {
600601
type: "requestfailed",
601-
url: request.url(),
602+
url: redactBrowserNetworkUrl(request.url()),
602603
method: request.method(),
603604
resourceType: request.resourceType(),
604605
timing: browserRequestTiming(request),
605-
failure: request.failure(),
606+
failure: failure ? { errorText: redactString(failure.errorText, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true }) } : null,
606607
timestamp,
607608
}
608609
}
609610

611+
function redactBrowserNetworkUrl(url: string): string {
612+
return redactString(url, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
613+
}
614+
610615
function browserRequestTiming(request: Request): Record<string, number> {
611616
return Object.fromEntries(
612617
Object.entries(request.timing()).filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1])),

packages/runtime-playground/src/browser-preview-routing.ts

Lines changed: 94 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,11 @@ export interface BrowserPreviewTopology {
5353
export interface BrowserPreviewRouteTracker {
5454
pending: Set<Promise<void>>
5555
errors: unknown[]
56+
registrations: number
5657
}
5758

5859
export function createBrowserPreviewRouteTracker(): BrowserPreviewRouteTracker {
59-
return { pending: new Set(), errors: [] }
60+
return { pending: new Set(), errors: [], registrations: 0 }
6061
}
6162

6263
export function browserPreviewRouting(args: string[], runtimeSpec: RuntimeCreateSpec | undefined, localPreviewOrigin: string): BrowserProbePreviewRouting {
@@ -271,19 +272,23 @@ export async function routeBrowserPreviewContextNetwork(context: import("playwri
271272

272273
export async function drainBrowserPreviewRouteTracker(tracker: BrowserPreviewRouteTracker, timeoutMs = BROWSER_PREVIEW_ROUTE_DRAIN_TIMEOUT_MS): Promise<void> {
273274
const deadline = Date.now() + timeoutMs
274-
while (tracker.pending.size > 0) {
275+
let observedRegistrations = -1
276+
while (tracker.pending.size > 0 || tracker.registrations !== observedRegistrations) {
277+
observedRegistrations = tracker.registrations
275278
const remainingMs = deadline - Date.now()
276279
if (remainingMs <= 0) {
277280
throw new Error(`wordpress.browser-probe route-host timed out waiting for ${tracker.pending.size} routed request(s) to finish`)
278281
}
279282

283+
const pending = [...tracker.pending]
280284
const result = await Promise.race([
281-
Promise.allSettled([...tracker.pending]).then(() => "drained" as const),
285+
Promise.allSettled(pending).then(() => "drained" as const),
282286
wait(remainingMs).then(() => "timeout" as const),
283287
])
284288
if (result === "timeout") {
285289
throw new Error(`wordpress.browser-probe route-host timed out waiting for ${tracker.pending.size} routed request(s) to finish`)
286290
}
291+
await wait(0)
287292
}
288293

289294
if (tracker.errors.length > 0) {
@@ -307,45 +312,67 @@ async function routeBrowserPreviewNetwork(routePattern: (url: string, handler: (
307312

308313
const origin = new URL(previewOrigin)
309314
await routePattern("**/*", async (route) => {
310-
const request = route.request()
311-
let requestUrl: URL
312-
try {
313-
requestUrl = new URL(request.url())
314-
} catch {
315-
await route.continue()
316-
return
317-
}
318-
319-
const host = normalizeBrowserPreviewHost(requestUrl.hostname)
320-
const stat = browserPreviewNetworkPolicyHostStat(policy, host)
321-
stat.requests += 1
322-
stat.external = !policy.firstPartyHosts.has(host)
323-
324-
if (policy.blockHosts.has(host) || (policy.mode === "block" && stat.external && !policy.allowHosts.has(host))) {
325-
stat.blocked += 1
326-
await route.abort("blockedbyclient")
327-
return
328-
}
329-
330-
if (!policy.routeHosts.has(host)) {
331-
await route.continue()
332-
return
315+
let operation = "inspect-request"
316+
const task = handleBrowserPreviewRoute(route, policy, origin, (nextOperation) => {
317+
operation = nextOperation
318+
})
319+
if (tracker) {
320+
tracker.registrations += 1
321+
tracker.pending.add(task)
333322
}
334-
335-
stat.routed += 1
336-
const task = fulfillBrowserPreviewRoutedHost(route, requestUrl, policy, origin)
337-
tracker?.pending.add(task)
338323
try {
339324
await task
340325
} catch (error) {
341-
tracker?.errors.push(sanitizeBrowserPreviewRouteError(error))
342-
await route.abort("failed").catch(() => undefined)
326+
if (!isBrowserPreviewRouteClosedError(error)) {
327+
tracker?.errors.push(browserPreviewRouteCallbackError(route, operation, error))
328+
}
329+
try {
330+
await route.abort("failed")
331+
} catch (abortError) {
332+
if (!isBrowserPreviewRouteClosedError(abortError)) {
333+
tracker?.errors.push(browserPreviewRouteCallbackError(route, "abort-after-error", abortError))
334+
}
335+
}
343336
} finally {
344337
tracker?.pending.delete(task)
345338
}
346339
})
347340
}
348341

342+
async function handleBrowserPreviewRoute(route: Route, policy: BrowserPreviewNetworkPolicy, origin: URL, setOperation: (operation: string) => void): Promise<void> {
343+
const request = route.request()
344+
let requestUrl: URL
345+
try {
346+
requestUrl = new URL(request.url())
347+
} catch {
348+
setOperation("continue-invalid-url")
349+
await route.continue()
350+
return
351+
}
352+
353+
const host = normalizeBrowserPreviewHost(requestUrl.hostname)
354+
const stat = browserPreviewNetworkPolicyHostStat(policy, host)
355+
stat.requests += 1
356+
stat.external = !policy.firstPartyHosts.has(host)
357+
358+
if (policy.blockHosts.has(host) || (policy.mode === "block" && stat.external && !policy.allowHosts.has(host))) {
359+
stat.blocked += 1
360+
setOperation("abort-policy-block")
361+
await route.abort("blockedbyclient")
362+
return
363+
}
364+
365+
if (!policy.routeHosts.has(host)) {
366+
setOperation("continue-unrouted")
367+
await route.continue()
368+
return
369+
}
370+
371+
stat.routed += 1
372+
setOperation("fulfill-routed-host")
373+
await fulfillBrowserPreviewRoutedHost(route, requestUrl, policy, origin)
374+
}
375+
349376
async function fulfillBrowserPreviewRoutedHost(route: Route, requestUrl: URL, policy: BrowserPreviewNetworkPolicy, localOrigin: URL): Promise<void> {
350377
const response = await fetchBrowserPreviewRoutedHost(route, requestUrl, policy, localOrigin)
351378
if (!response) {
@@ -430,7 +457,9 @@ async function fetchBrowserPreviewRoutedHost(route: Route, requestUrl: URL, poli
430457

431458
let response: Awaited<ReturnType<Route["fetch"]>> | undefined
432459
const resourceType = route.request().resourceType()
433-
const maxAttempts = resourceType === "document" ? BROWSER_PREVIEW_ROUTE_DOCUMENT_FETCH_ATTEMPTS : BROWSER_PREVIEW_ROUTE_SUBRESOURCE_FETCH_ATTEMPTS
460+
const method = route.request().method().toUpperCase()
461+
const methodCanRetry = browserPreviewRouteMethodCanRetry(method)
462+
const maxAttempts = methodCanRetry ? (resourceType === "document" ? BROWSER_PREVIEW_ROUTE_DOCUMENT_FETCH_ATTEMPTS : BROWSER_PREVIEW_ROUTE_SUBRESOURCE_FETCH_ATTEMPTS) : 1
434463
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
435464
try {
436465
response = await route.fetch({
@@ -466,6 +495,9 @@ async function fetchBrowserPreviewRoutedHost(route: Route, requestUrl: URL, poli
466495
if (!response) {
467496
return undefined
468497
}
498+
if (!methodCanRetry) {
499+
return response
500+
}
469501

470502
const location = response.headers().location
471503
if (!location || response.status() < 300 || response.status() >= 400) {
@@ -514,6 +546,35 @@ export function isBrowserPreviewRouteFetchTransientTransportError(error: unknown
514546
return error instanceof Error && /\b(?:ECONNRESET|ECONNREFUSED|EPIPE|ETIMEDOUT|UND_ERR_SOCKET|socket (?:hang up|closed|ended)|connection (?:reset|refused|closed)|other side closed)\b/i.test(error.message)
515547
}
516548

549+
export function isBrowserPreviewRouteClosedError(error: unknown): boolean {
550+
return error instanceof Error && /(?:Request context disposed|Target (?:page, context or browser|page|context|browser) has been closed|Browser has been closed|context closed|page closed)/i.test(error.message)
551+
}
552+
553+
function browserPreviewRouteMethodCanRetry(method: string): boolean {
554+
return method === "GET" || method === "HEAD" || method === "OPTIONS"
555+
}
556+
557+
function browserPreviewRouteCallbackError(route: Route, operation: string, error: unknown): Error {
558+
const request = browserPreviewRouteRequestSummary(route)
559+
const cause = sanitizeBrowserPreviewRouteError(error).message.replace(/[\r\n]+/g, " ")
560+
const diagnostic = new Error(`wordpress.browser-probe route callback failed: operation=${operation} method=${request.method} resourceType=${request.resourceType} url=${request.url} cause=${cause}`)
561+
diagnostic.name = "BrowserPreviewRouteCallbackError"
562+
return diagnostic
563+
}
564+
565+
function browserPreviewRouteRequestSummary(route: Route): { method: string; resourceType: string; url: string } {
566+
try {
567+
const request = route.request()
568+
return {
569+
method: request.method(),
570+
resourceType: request.resourceType(),
571+
url: redactString(request.url(), { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true }),
572+
}
573+
} catch {
574+
return { method: "unknown", resourceType: "unknown", url: "[unavailable]" }
575+
}
576+
}
577+
517578
function browserPreviewRouteFetchExhaustedError(route: Route, requestUrl: URL, attempts: number, error: unknown): Error {
518579
const method = route.request().method()
519580
const resourceType = route.request().resourceType()

packages/runtime-playground/src/browser-probe-runner.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -451,19 +451,10 @@ export async function runSingleBrowserProbeCommand({
451451
} finally {
452452
if (abortSignal?.aborted) {
453453
await closeBrowserBestEffort(browser)
454+
await drainBrowserPreviewRouteTracker(routeTracker).catch(() => undefined)
454455
abortSignal.removeEventListener("abort", abortHandler)
455456
throw pendingError ?? new Error("Browser command aborted during runtime cleanup")
456457
}
457-
try {
458-
await drainBrowserPreviewRouteTracker(routeTracker)
459-
} catch (error) {
460-
const routeError = redactError(error, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
461-
if (!pendingError && runPlan.routeHostDrain === "required") {
462-
pendingError = routeError
463-
progress.fail("probe-error", routeError)
464-
}
465-
errors.push(serializeBrowserError("probe-error", error))
466-
}
467458
if (page) {
468459
finalUrl = page.url()
469460
windowLocationOrigin = windowLocationOrigin ?? await page.evaluate(() => window.location.origin).catch(() => undefined)
@@ -504,6 +495,16 @@ export async function runSingleBrowserProbeCommand({
504495
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
505496
await geolocationPermissionCleanup?.()
506497
await browser.close()
498+
try {
499+
await drainBrowserPreviewRouteTracker(routeTracker)
500+
} catch (error) {
501+
const routeError = redactError(error, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
502+
if (!pendingError && runPlan.routeHostDrain === "required") {
503+
pendingError = routeError
504+
progress.fail("probe-error", routeError)
505+
}
506+
errors.push(serializeBrowserError("probe-error", error))
507+
}
507508
if (captureSelection.console) {
508509
await artifactSession.writeJsonLines("console", "console.jsonl", consoleMessages)
509510
}

packages/runtime-playground/src/browser-probe-session-result-builder.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,8 @@ export interface BrowserProbeSessionResult {
8686
}
8787

8888
export class BrowserProbeSessionResultBuilder {
89-
compose(input: BrowserProbeSessionResultInput): BrowserProbeSessionResult {
89+
compose(rawInput: BrowserProbeSessionResultInput): BrowserProbeSessionResult {
90+
const input = sanitizeBrowserProbeSessionResultInput(rawInput)
9091
const assertionSummary = browserProbeAssertionSummary(input.assertions)
9192
const finishedAt = now()
9293
const files = browserProbeArtifactFileMap(input)
@@ -158,6 +159,27 @@ export class BrowserProbeSessionResultBuilder {
158159
}
159160
}
160161

162+
function sanitizeBrowserProbeSessionResultInput(input: BrowserProbeSessionResultInput): BrowserProbeSessionResultInput {
163+
const safeUrl = (url: string): string => safeBrowserProbeUrl(url) ?? "[unavailable]"
164+
return {
165+
...input,
166+
requestedUrl: safeUrl(input.requestedUrl),
167+
finalUrl: safeUrl(input.finalUrl),
168+
network: input.network.map((record) => ({ ...record, url: safeUrl(record.url) })),
169+
preview: {
170+
...input.preview,
171+
localOrigin: safeUrl(input.preview.localOrigin),
172+
effectiveOrigin: safeUrl(input.preview.effectiveOrigin),
173+
...(input.preview.publicOrigin ? { publicOrigin: safeUrl(input.preview.publicOrigin) } : {}),
174+
},
175+
topologyOrigins: {
176+
localPreviewOrigin: safeUrl(input.topologyOrigins.localPreviewOrigin),
177+
effectivePreviewOrigin: safeUrl(input.topologyOrigins.effectivePreviewOrigin),
178+
...(input.topologyOrigins.requestedPreviewOrigin ? { requestedPreviewOrigin: safeUrl(input.topologyOrigins.requestedPreviewOrigin) } : {}),
179+
},
180+
}
181+
}
182+
161183
function browserProbeArtifactFileMap(input: BrowserProbeSessionResultInput): BrowserProbeArtifact["files"] {
162184
return {
163185
...(input.capture.has("console") || input.captureSelection?.consoleForAssertions ? { console: `${input.browserFilesDirectory}/console.jsonl` } : {}),

packages/runtime-playground/src/browser-probe-support.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,7 @@ export function safeBrowserProbeUrl(value: string | undefined): string | null {
287287
if (/^data:/i.test(value)) {
288288
return "data:[redacted]"
289289
}
290-
return value
290+
return browserRedirectSafeUrl(value)
291291
}
292292

293293
export interface BrowserRedirectDiagnosticsArtifact {
@@ -400,7 +400,7 @@ export function browserRedirectSafeUrl(value: string): string {
400400
}
401401
const parsed = parseBrowserRedirectUrl(value)
402402
if (!parsed) {
403-
return value
403+
return redactString(value, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
404404
}
405405
const search = parsed.queryKeys.length > 0
406406
? `?${parsed.queryKeys.map((key) => `${encodeURIComponent(key)}=[redacted]`).join("&")}`

scripts/smoke-manifest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ export const smokeGroups = {
4747
npmScript("test:browser-runner-template"),
4848
npmScript("test:browser-runtime-file-ops"),
4949
npmScript("test:browser-provider-bridge-inheritance"),
50+
npmScript("test:browser-preview-routing"),
5051
tsxSmoke("runtime-backend-registry-smoke"),
5152
tsxSmoke("backend-package-adapter-registry-smoke"),
5253
tsxSmoke("command-registry-smoke"),

0 commit comments

Comments
 (0)