Skip to content

Commit 9644f63

Browse files
committed
fix: harden routed preview recovery boundaries
1 parent e58ea10 commit 9644f63

8 files changed

Lines changed: 322 additions & 85 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ jobs:
129129
- run: npm run test:trusted-apply-artifact-channel
130130
- run: npm run test:runtime-command-artifact-bounds
131131
- run: npm run test:redaction
132+
- run: npm run test:browser-preview-routing
132133
- run: npm run test:production-boundary-enforcement
133134
- run: npm run test:runtime-tool-policy
134135

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: 105 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -64,10 +64,11 @@ export interface BrowserPreviewOrigins {
6464
export interface BrowserPreviewRouteTracker {
6565
pending: Set<Promise<void>>
6666
errors: unknown[]
67+
registrations: number
6768
}
6869

6970
export function createBrowserPreviewRouteTracker(): BrowserPreviewRouteTracker {
70-
return { pending: new Set(), errors: [] }
71+
return { pending: new Set(), errors: [], registrations: 0 }
7172
}
7273

7374
export function browserPreviewRouting(args: string[], runtimeSpec: RuntimeCreateSpec | undefined, localPreviewOrigin: string): BrowserProbePreviewRouting {
@@ -325,19 +326,23 @@ export async function routeBrowserPreviewContextNetwork(context: import("playwri
325326

326327
export async function drainBrowserPreviewRouteTracker(tracker: BrowserPreviewRouteTracker, timeoutMs = BROWSER_PREVIEW_ROUTE_DRAIN_TIMEOUT_MS): Promise<void> {
327328
const deadline = Date.now() + timeoutMs
328-
while (tracker.pending.size > 0) {
329+
let observedRegistrations = -1
330+
while (tracker.pending.size > 0 || tracker.registrations !== observedRegistrations) {
331+
observedRegistrations = tracker.registrations
329332
const remainingMs = deadline - Date.now()
330333
if (remainingMs <= 0) {
331334
throw new Error(`wordpress.browser-probe route-host timed out waiting for ${tracker.pending.size} routed request(s) to finish`)
332335
}
333336

337+
const pending = [...tracker.pending]
334338
const result = await Promise.race([
335-
Promise.allSettled([...tracker.pending]).then(() => "drained" as const),
339+
Promise.allSettled(pending).then(() => "drained" as const),
336340
wait(remainingMs).then(() => "timeout" as const),
337341
])
338342
if (result === "timeout") {
339343
throw new Error(`wordpress.browser-probe route-host timed out waiting for ${tracker.pending.size} routed request(s) to finish`)
340344
}
345+
await wait(0)
341346
}
342347

343348
if (tracker.errors.length > 0) {
@@ -361,53 +366,77 @@ async function routeBrowserPreviewNetwork(routePattern: (url: string, handler: (
361366

362367
const origin = new URL(previewOrigin)
363368
await routePattern("**/*", async (route) => {
364-
const request = route.request()
365-
let requestUrl: URL
366-
try {
367-
requestUrl = new URL(request.url())
368-
} catch {
369-
await route.continue()
370-
return
371-
}
372-
373-
const host = normalizeBrowserPreviewHost(requestUrl.hostname)
374-
const stat = browserPreviewNetworkPolicyHostStat(policy, host)
375-
stat.requests += 1
376-
stat.external = !policy.firstPartyHosts.has(host)
377-
378-
if (policy.blockHosts.has(host)) {
379-
stat.blocked += 1
380-
await route.abort("blockedbyclient")
381-
return
369+
let operation = "inspect-request"
370+
const task = handleBrowserPreviewRoute(route, policy, origin, (nextOperation) => {
371+
operation = nextOperation
372+
})
373+
if (tracker) {
374+
tracker.registrations += 1
375+
tracker.pending.add(task)
382376
}
383-
384-
if (policy.routeHosts.has(host)) {
385-
stat.routed += 1
386-
if (policy.preserveRoutedOrigin) {
387-
await route.continue()
388-
return
377+
try {
378+
await task
379+
} catch (error) {
380+
if (!isBrowserPreviewRouteClosedError(error)) {
381+
tracker?.errors.push(browserPreviewRouteCallbackError(route, operation, error))
389382
}
390-
const task = fulfillBrowserPreviewRoutedHost(route, requestUrl, policy, origin)
391-
tracker?.pending.add(task)
392383
try {
393-
await task
394-
} catch (error) {
395-
tracker?.errors.push(sanitizeBrowserPreviewRouteError(error))
396-
await route.abort("failed").catch(() => undefined)
397-
} finally {
398-
tracker?.pending.delete(task)
384+
await route.abort("failed")
385+
} catch (abortError) {
386+
if (!isBrowserPreviewRouteClosedError(abortError)) {
387+
tracker?.errors.push(browserPreviewRouteCallbackError(route, "abort-after-error", abortError))
388+
}
399389
}
400-
return
390+
} finally {
391+
tracker?.pending.delete(task)
401392
}
393+
})
394+
}
402395

403-
if (policy.preserveRoutedOrigin || (policy.mode === "block" && stat.external && !policy.allowHosts.has(host)) || (request.resourceType() === "document" && stat.external)) {
404-
stat.blocked += 1
405-
await route.abort("blockedbyclient")
396+
async function handleBrowserPreviewRoute(route: Route, policy: BrowserPreviewNetworkPolicy, origin: URL, setOperation: (operation: string) => void): Promise<void> {
397+
const request = route.request()
398+
let requestUrl: URL
399+
try {
400+
requestUrl = new URL(request.url())
401+
} catch {
402+
setOperation("continue-invalid-url")
403+
await route.continue()
404+
return
405+
}
406+
407+
const host = normalizeBrowserPreviewHost(requestUrl.hostname)
408+
const stat = browserPreviewNetworkPolicyHostStat(policy, host)
409+
stat.requests += 1
410+
stat.external = !policy.firstPartyHosts.has(host)
411+
412+
if (policy.blockHosts.has(host)) {
413+
stat.blocked += 1
414+
setOperation("abort-policy-block")
415+
await route.abort("blockedbyclient")
416+
return
417+
}
418+
419+
if (policy.routeHosts.has(host)) {
420+
stat.routed += 1
421+
if (policy.preserveRoutedOrigin) {
422+
setOperation("continue-preserved-routed-origin")
423+
await route.continue()
406424
return
407425
}
426+
setOperation("fulfill-routed-host")
427+
await fulfillBrowserPreviewRoutedHost(route, requestUrl, policy, origin)
428+
return
429+
}
408430

409-
await route.continue()
410-
})
431+
if (policy.preserveRoutedOrigin || (policy.mode === "block" && stat.external && !policy.allowHosts.has(host)) || (request.resourceType() === "document" && stat.external)) {
432+
stat.blocked += 1
433+
setOperation("abort-policy-block")
434+
await route.abort("blockedbyclient")
435+
return
436+
}
437+
438+
setOperation("continue-unrouted")
439+
await route.continue()
411440
}
412441

413442
async function fulfillBrowserPreviewRoutedHost(route: Route, requestUrl: URL, policy: BrowserPreviewNetworkPolicy, localOrigin: URL): Promise<void> {
@@ -494,7 +523,9 @@ async function fetchBrowserPreviewRoutedHost(route: Route, requestUrl: URL, poli
494523

495524
let response: Awaited<ReturnType<Route["fetch"]>> | undefined
496525
const resourceType = route.request().resourceType()
497-
const maxAttempts = resourceType === "document" ? BROWSER_PREVIEW_ROUTE_DOCUMENT_FETCH_ATTEMPTS : BROWSER_PREVIEW_ROUTE_SUBRESOURCE_FETCH_ATTEMPTS
526+
const method = route.request().method().toUpperCase()
527+
const methodCanRetry = browserPreviewRouteMethodCanRetry(method)
528+
const maxAttempts = methodCanRetry ? (resourceType === "document" ? BROWSER_PREVIEW_ROUTE_DOCUMENT_FETCH_ATTEMPTS : BROWSER_PREVIEW_ROUTE_SUBRESOURCE_FETCH_ATTEMPTS) : 1
498529
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
499530
try {
500531
response = await route.fetch({
@@ -530,6 +561,9 @@ async function fetchBrowserPreviewRoutedHost(route: Route, requestUrl: URL, poli
530561
if (!response) {
531562
return undefined
532563
}
564+
if (!methodCanRetry) {
565+
return response
566+
}
533567

534568
const location = response.headers().location
535569
if (!location || response.status() < 300 || response.status() >= 400) {
@@ -578,6 +612,35 @@ export function isBrowserPreviewRouteFetchTransientTransportError(error: unknown
578612
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)
579613
}
580614

615+
export function isBrowserPreviewRouteClosedError(error: unknown): boolean {
616+
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)
617+
}
618+
619+
function browserPreviewRouteMethodCanRetry(method: string): boolean {
620+
return method === "GET" || method === "HEAD" || method === "OPTIONS"
621+
}
622+
623+
function browserPreviewRouteCallbackError(route: Route, operation: string, error: unknown): Error {
624+
const request = browserPreviewRouteRequestSummary(route)
625+
const cause = sanitizeBrowserPreviewRouteError(error).message.replace(/[\r\n]+/g, " ")
626+
const diagnostic = new Error(`wordpress.browser-probe route callback failed: operation=${operation} method=${request.method} resourceType=${request.resourceType} url=${request.url} cause=${cause}`)
627+
diagnostic.name = "BrowserPreviewRouteCallbackError"
628+
return diagnostic
629+
}
630+
631+
function browserPreviewRouteRequestSummary(route: Route): { method: string; resourceType: string; url: string } {
632+
try {
633+
const request = route.request()
634+
return {
635+
method: request.method(),
636+
resourceType: request.resourceType(),
637+
url: redactString(request.url(), { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true }),
638+
}
639+
} catch {
640+
return { method: "unknown", resourceType: "unknown", url: "[unavailable]" }
641+
}
642+
}
643+
581644
function browserPreviewRouteFetchExhaustedError(route: Route, requestUrl: URL, attempts: number, error: unknown): Error {
582645
const method = route.request().method()
583646
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
@@ -452,19 +452,10 @@ export async function runSingleBrowserProbeCommand({
452452
} finally {
453453
if (abortSignal?.aborted) {
454454
await closeBrowserBestEffort(browser)
455+
await drainBrowserPreviewRouteTracker(routeTracker).catch(() => undefined)
455456
abortSignal.removeEventListener("abort", abortHandler)
456457
throw pendingError ?? new Error("Browser command aborted during runtime cleanup")
457458
}
458-
try {
459-
await drainBrowserPreviewRouteTracker(routeTracker)
460-
} catch (error) {
461-
const routeError = redactError(error, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
462-
if (!pendingError && runPlan.routeHostDrain === "required") {
463-
pendingError = routeError
464-
progress.fail("probe-error", routeError)
465-
}
466-
errors.push(serializeBrowserError("probe-error", error))
467-
}
468459
if (page) {
469460
finalUrl = page.url()
470461
windowLocationOrigin = windowLocationOrigin ?? await page.evaluate(() => window.location.origin).catch(() => undefined)
@@ -505,6 +496,16 @@ export async function runSingleBrowserProbeCommand({
505496
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
506497
await geolocationPermissionCleanup?.()
507498
await browser.close()
499+
try {
500+
await drainBrowserPreviewRouteTracker(routeTracker)
501+
} catch (error) {
502+
const routeError = redactError(error, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
503+
if (!pendingError && runPlan.routeHostDrain === "required") {
504+
pendingError = routeError
505+
progress.fail("probe-error", routeError)
506+
}
507+
errors.push(serializeBrowserError("probe-error", error))
508+
}
508509
if (captureSelection.console) {
509510
await artifactSession.writeJsonLines("console", "console.jsonl", consoleMessages)
510511
}

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)