Skip to content

Commit ab8f91c

Browse files
authored
Fix canonical routed preview origins (#2098)
* fix: preserve canonical routed preview origins * fix: enforce canonical preview boundaries * test: gate canonical preview origins in PR CI * ci: install Chromium for browser contracts
1 parent 6445ee1 commit ab8f91c

12 files changed

Lines changed: 412 additions & 50 deletions

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

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,14 @@ on:
2323
- "tests/redaction.test.ts"
2424
- "tests/production-boundary-enforcement.test.ts"
2525
- "tests/runtime-tool-policy.test.ts"
26+
- "tests/browser-canonical-preview-origin.test.ts"
27+
- "packages/runtime-playground/src/browser-actions-runner.ts"
28+
- "packages/runtime-playground/src/browser-artifacts.ts"
29+
- "packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts"
30+
- "packages/runtime-playground/src/browser-preview-routing.ts"
31+
- "packages/runtime-playground/src/browser-probe-runner.ts"
32+
- "packages/runtime-playground/src/editor-command-runners.ts"
33+
- "packages/runtime-playground/src/preview-server.ts"
2634
- "packages/runtime-playground/src/phpunit-command-handlers.ts"
2735
- "packages/runtime-playground/src/playground-cli-runner.ts"
2836
- "packages/runtime-playground/src/playground-wordpress-archive-cache.ts"
@@ -56,6 +64,14 @@ on:
5664
- "tests/redaction.test.ts"
5765
- "tests/production-boundary-enforcement.test.ts"
5866
- "tests/runtime-tool-policy.test.ts"
67+
- "tests/browser-canonical-preview-origin.test.ts"
68+
- "packages/runtime-playground/src/browser-actions-runner.ts"
69+
- "packages/runtime-playground/src/browser-artifacts.ts"
70+
- "packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts"
71+
- "packages/runtime-playground/src/browser-preview-routing.ts"
72+
- "packages/runtime-playground/src/browser-probe-runner.ts"
73+
- "packages/runtime-playground/src/editor-command-runners.ts"
74+
- "packages/runtime-playground/src/preview-server.ts"
5975
- "packages/runtime-playground/src/phpunit-command-handlers.ts"
6076
- "packages/runtime-playground/src/playground-cli-runner.ts"
6177
- "packages/runtime-playground/src/playground-wordpress-archive-cache.ts"
@@ -84,8 +100,16 @@ jobs:
84100
php-version: '8.2'
85101
tools: composer:v2
86102
- run: npm ci
103+
- name: Cache Playwright Chromium
104+
uses: actions/cache@v4
105+
with:
106+
path: ~/.cache/ms-playwright
107+
key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package-lock.json') }}
108+
- name: Install Playwright Chromium
109+
run: npx playwright install --with-deps chromium
87110
- run: npm run build
88111
- run: npm run test:agent-task-contracts
112+
- run: npm run test:browser-canonical-preview-origin
89113
- run: npm run test:bounded-runtime-plan
90114
- run: npm run test:bounded-recipe-plan
91115
- run: npm run test:bounded-recipe-plan-integration

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@
165165
"test:artifact-reference-dtos": "tsx tests/artifact-reference-dtos.test.ts",
166166
"test:artifact-path-primitives": "tsx tests/artifact-path-primitives.test.ts",
167167
"test:browser-callback-materialization-contracts": "tsx tests/browser-callback-materialization-contracts.test.ts",
168+
"test:browser-canonical-preview-origin": "tsx tests/browser-canonical-preview-origin.test.ts",
168169
"test:materialize-replay-package-command": "tsx tests/materialize-replay-package-command.test.ts",
169170
"test:source-package-compiler-primitives": "tsx tests/source-package-compiler-primitives.test.ts",
170171
"test:source-root-preparation": "tsx tests/source-root-preparation.test.ts",
@@ -177,7 +178,7 @@
177178
"test:native-agent-task-interruption": "node tests/execute-native-agent-task-interruption.test.mjs",
178179
"test:native-agent-task-playground-e2e": "tsx tests/execute-native-agent-task-playground-e2e.test.ts",
179180
"test:bench-command-step-behavior": "tsx tests/bench-command-step-behavior.test.ts",
180-
"test:generic-primitives": "npm run test:artifact-path-primitives && npm run test:browser-callback-materialization-contracts && npm run test:source-package-compiler-primitives && npm run test:bench-command-step-behavior && npm run test:generic-ability-runtime-run",
181+
"test:generic-primitives": "npm run test:artifact-path-primitives && npm run test:browser-callback-materialization-contracts && npm run test:browser-canonical-preview-origin && npm run test:source-package-compiler-primitives && npm run test:bench-command-step-behavior && npm run test:generic-ability-runtime-run",
181182
"test:browser-artifact-session": "tsx tests/browser-artifact-session.test.ts",
182183
"test:browser-environment-matrix": "tsx --test tests/browser-environment-matrix.test.ts tests/browser-environment-matrix.browser.test.ts",
183184
"test:browser-diagnostic-providers": "tsx tests/browser-diagnostic-providers.test.ts",

packages/runtime-core/src/artifact-review.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,9 @@ export interface ArtifactReviewBrowserSummary {
9292
localPreviewOrigin?: string
9393
requestedPreviewOrigin?: string
9494
effectivePreviewOrigin?: string
95+
canonicalBrowserOrigin?: string
96+
localProxyOrigin?: string
97+
upstreamRuntimeOrigin?: string
9598
finalUrl?: string
9699
windowLocationOrigin?: string
97100
viewport?: {

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import { browserAssertionsSummary, browserStepRecord, executeBrowserInteractionS
1212
import { browserCommandLivenessPolicy, isBrowserCommandLivenessError, withBrowserCommandLiveness } from "./browser-liveness.js"
1313
import { serializeBrowserError } from "./browser-metrics.js"
1414
import { executeBrowserObservationAssertion } from "./browser-observation-assertions.js"
15-
import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
15+
import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
1616
import { BROWSER_PROBE_STATE_INIT_SCRIPT, browserProbeReplayability, browserProbeViewport } from "./browser-probe.js"
1717
import { runBrowserProbeCommand, type BrowserProbeRunPlan } from "./browser-probe-runner.js"
1818
import { browserActionTargetUrls, browserAuthRequest, browserProbeWaterfallArtifact, browserProbeWebSocketArtifact, browserProbeWebSocketSummary, browserRedirectDiagnosticsArtifact, browserRequestCoverageArtifact, browserStorageStateAuthSummary, browserStorageStateImportFromArgs, browserWordPressDiagnosticsArtifact, createBrowserProbeProgressTracker, fileSha256, installBrowserWordPressDiagnostics, installWordPressAdminAuthCookies, livenessRemainingWallTimeMs, normalizeBrowserProbeScriptCheckpoint, type BrowserCommandProgressEvent, type BrowserStorageStateImport } from "./browser-probe-support.js"
@@ -125,7 +125,7 @@ export async function runBrowserActionsCommand({
125125
const startedAtMs = Date.now()
126126
const progress = createBrowserProbeProgressTracker(startedAt, 0)
127127
const browser = await launchChromiumBrowser()
128-
const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl)
128+
const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin)
129129
const { preview, networkPolicy } = topology
130130
let requestedUrl = initialUrl ? topology.resolveUrl(initialUrl) : preview.effectiveOrigin
131131
let finalUrl = requestedUrl
@@ -144,11 +144,16 @@ export async function runBrowserActionsCommand({
144144
let adaptiveExplorationSummary: BrowserArtifact["summary"]["adaptiveExploration"] | undefined
145145

146146
try {
147+
const previewReadinessError = browserPreviewReadinessError(preview)
148+
if (previewReadinessError) {
149+
throw previewReadinessError
150+
}
147151
const context = browserPreviewNeedsContextRouting(networkPolicy) || !!storageStateImport ? await browser.newContext({
152+
...topology.contextOptions(),
148153
...(storageStateImport ? { storageState: storageStateImport.storageState } : {}),
149154
}) : null
150155
if (context) {
151-
await routeBrowserPreviewContextNetwork(context, networkPolicy, preview.effectiveOrigin)
156+
await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin)
152157
}
153158
const page = context ? await context.newPage() : await browser.newPage()
154159
if (onProgress) {
@@ -409,6 +414,9 @@ export async function runBrowserActionsCommand({
409414
}
410415
}
411416
}
417+
} catch (error) {
418+
pendingError = error instanceof Error ? error : new Error(String(error))
419+
errors.push(serializeBrowserError("probe-error", error))
412420
} finally {
413421
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
414422
await browser.close()

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ export interface BrowserArtifactBase {
1717
localPreviewOrigin?: string
1818
requestedPreviewOrigin?: string
1919
effectivePreviewOrigin?: string
20+
canonicalBrowserOrigin?: string
21+
localProxyOrigin?: string
22+
upstreamRuntimeOrigin?: string
2023
prePageScript?: BrowserProbeScriptMetadata
2124
files: BrowserArtifactFiles
2225
summary: BrowserArtifactSummary
@@ -1043,6 +1046,9 @@ export function browserReviewSummary(probes: BrowserArtifact[]): ArtifactReviewB
10431046
localPreviewOrigin: probe.localPreviewOrigin,
10441047
requestedPreviewOrigin: probe.requestedPreviewOrigin,
10451048
effectivePreviewOrigin: probe.effectivePreviewOrigin,
1049+
canonicalBrowserOrigin: probe.canonicalBrowserOrigin,
1050+
localProxyOrigin: probe.localProxyOrigin,
1051+
upstreamRuntimeOrigin: probe.upstreamRuntimeOrigin,
10461052
finalUrl: probe.summary.finalUrl,
10471053
windowLocationOrigin: probe.summary.windowLocationOrigin,
10481054
viewport: probe.summary.viewport,

packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { BrowserCommandArtifactError } from "./browser-command-artifact-error.js
66
import { attachBrowserCaptureListeners, launchChromiumBrowser, settleBrowserNetworkTasks } from "./browser-capture-session.js"
77
import { executeBrowserInteractionStep } from "./browser-interactions.js"
88
import { browserProbeReplayability } from "./browser-probe.js"
9-
import { browserPreviewTopology, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
9+
import { browserPreviewReadinessError, browserPreviewTopology, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
1010
import { installWordPressAdminAuthCookies } from "./browser-probe-support.js"
1111
import { bootstrapPhpCode } from "./php-bootstrap.js"
1212
import { assertPlaygroundResponseOk, type PlaygroundRunResponse } from "./playground-command-errors.js"
@@ -28,13 +28,18 @@ export async function runBrowserMultiActorScenarioCommand(input: {
2828
// Traces are always retained for replay, even when callers narrow display captures.
2929
const captures = new Set([...(scenario.captures ?? ["steps", "console", "errors", "network", "screenshot"]), "trace"])
3030
const artifacts = new BrowserArtifactSession(artifactRoot, "files/browser", { source: "wordpress.browser-scenario", operation: "browser-multi-actor-scenario" })
31-
const topology = browserPreviewTopology([], runtimeSpec, server.serverUrl)
31+
const routeHost = runtimeSpec.preview?.siteUrl ? new URL(runtimeSpec.preview.siteUrl).hostname : ""
32+
const topology = browserPreviewTopology(routeHost ? [`route-host=${routeHost}`] : [], runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin)
3233
const browser = await launchChromiumBrowser()
3334
const evidence: Record<string, ActorEvidence> = {}
3435
let result: BrowserMultiActorScenarioResult | undefined
3536
let failure: Error | undefined
3637

3738
try {
39+
const previewReadinessError = browserPreviewReadinessError(topology.preview)
40+
if (previewReadinessError) {
41+
throw previewReadinessError
42+
}
3843
const clientEntries: Array<[string, BrowserMultiActorClient]> = []
3944
const actorPages: Array<{ actor: string; page: Pick<Page, "goto"> }> = []
4045
// Playground PHP commands share one runtime endpoint, so provision identities
@@ -43,8 +48,8 @@ export async function runBrowserMultiActorScenarioCommand(input: {
4348
const session = wordpressUserSessionFromCommandArgs([`session=${actor.userSession}`], runtimeSpec)
4449
if (!session) throw new Error(`Actor ${actor.name} requires user session ${actor.userSession}`)
4550
const userId = await actorUserId(actor.name, session.user.userId, session.user, runtimeSpec, runPlaygroundCommand, server)
46-
const context = await browser.newContext()
47-
await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.preview.effectiveOrigin)
51+
const context = await browser.newContext(topology.contextOptions())
52+
await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.origins.localProxyOrigin)
4853
const page = await context.newPage()
4954
await context.tracing.start({ screenshots: true, snapshots: true })
5055
await installWordPressAdminAuthCookies({ command: "wordpress.browser-scenario", cookieUrls: topology.authCookieUrls([topology.resolveUrl(scenario.url)]), page, runPlaygroundCommand, runtimeSpec, server, userId })

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

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ export interface BrowserPreviewNetworkPolicy {
1717
recordExternal: boolean
1818
stats: Map<string, { requests: number; external: boolean; blocked: number; routed: number }>
1919
routedRedirectEscapes: Array<{ rawOrigin: string; effectiveOrigin: string; reason: string }>
20+
preserveRoutedOrigin: boolean
2021
}
2122

2223
export interface BrowserPreviewNavigationDecision {
@@ -44,10 +45,20 @@ export interface BrowserPreviewTopology {
4445
preview: BrowserProbePreviewRouting
4546
networkPolicy: BrowserPreviewNetworkPolicy
4647
routedHosts: string[]
47-
origins: { localPreviewOrigin: string; requestedPreviewOrigin?: string; effectivePreviewOrigin: string }
48+
origins: BrowserPreviewOrigins
4849
navigationScope: BrowserPreviewNavigationScope
4950
resolveUrl(pathOrUrl: string): string
5051
authCookieUrls(targetUrls: string[]): string[]
52+
contextOptions(): { proxy?: { server: string } }
53+
}
54+
55+
export interface BrowserPreviewOrigins {
56+
localPreviewOrigin: string
57+
requestedPreviewOrigin?: string
58+
effectivePreviewOrigin: string
59+
canonicalBrowserOrigin: string
60+
localProxyOrigin: string
61+
upstreamRuntimeOrigin?: string
5162
}
5263

5364
export interface BrowserPreviewRouteTracker {
@@ -97,23 +108,27 @@ export function browserPreviewRouting(args: string[], runtimeSpec: RuntimeCreate
97108
}
98109
}
99110

100-
export function browserPreviewTopology(args: string[], runtimeSpec: RuntimeCreateSpec | undefined, localPreviewOrigin: string): BrowserPreviewTopology {
101-
const preview = browserPreviewRouting(args, runtimeSpec, localPreviewOrigin)
111+
export function browserPreviewTopology(args: string[], runtimeSpec: RuntimeCreateSpec | undefined, localPreviewOrigin: string, upstreamRuntimeOrigin?: string): BrowserPreviewTopology {
102112
const routedHosts = commaListArg(args, "route-host")
113+
const preview = browserPreviewRouting(args, runtimeSpec, localPreviewOrigin)
114+
applyCanonicalRoutedPreviewOrigin(preview, runtimeSpec?.preview?.siteUrl, routedHosts)
103115
const networkPolicy = browserPreviewNetworkPolicy(args, routedHosts, preview)
104116

105117
return {
106118
preview,
107119
networkPolicy,
108120
routedHosts,
109-
origins: browserPreviewOrigins(preview),
121+
origins: browserPreviewOrigins(preview, upstreamRuntimeOrigin),
110122
navigationScope: browserPreviewNavigationScope(preview.effectiveOrigin, networkPolicy),
111123
resolveUrl(pathOrUrl) {
112124
return resolveBrowserPreviewUrl(pathOrUrl, preview.effectiveOrigin)
113125
},
114126
authCookieUrls(targetUrls) {
115127
return browserPreviewAuthCookieUrls(localPreviewOrigin, routedHosts, targetUrls)
116128
},
129+
contextOptions() {
130+
return networkPolicy.preserveRoutedOrigin ? { proxy: { server: new URL(localPreviewOrigin).origin } } : {}
131+
},
117132
}
118133
}
119134

@@ -146,14 +161,52 @@ export function browserPreviewNavigationScope(effectivePreviewOrigin: string, po
146161
}
147162
}
148163

149-
export function browserPreviewOrigins(preview: BrowserProbePreviewRouting): { localPreviewOrigin: string; requestedPreviewOrigin?: string; effectivePreviewOrigin: string } {
164+
export function browserPreviewOrigins(preview: BrowserProbePreviewRouting, upstreamRuntimeOrigin?: string): BrowserPreviewOrigins {
150165
return {
151166
localPreviewOrigin: preview.localOrigin,
152-
requestedPreviewOrigin: preview.publicOrigin,
167+
...(preview.publicOrigin ? { requestedPreviewOrigin: preview.publicOrigin } : {}),
153168
effectivePreviewOrigin: preview.effectiveOrigin,
169+
canonicalBrowserOrigin: new URL(preview.effectiveOrigin).origin,
170+
localProxyOrigin: new URL(preview.localOrigin).origin,
171+
...(upstreamRuntimeOrigin ? { upstreamRuntimeOrigin: new URL(upstreamRuntimeOrigin).origin } : {}),
154172
}
155173
}
156174

175+
function applyCanonicalRoutedPreviewOrigin(preview: BrowserProbePreviewRouting, siteUrl: string | undefined, routedHosts: string[]): void {
176+
if (preview.effectiveMode !== "local" || !siteUrl) {
177+
return
178+
}
179+
180+
let canonical: URL
181+
try {
182+
canonical = new URL(siteUrl)
183+
} catch {
184+
return
185+
}
186+
const host = normalizeBrowserPreviewHost(canonical.hostname)
187+
if (!routedHosts.map(normalizeBrowserPreviewHost).includes(host)) {
188+
return
189+
}
190+
191+
if (canonical.protocol !== "http:") {
192+
preview.diagnostics.push({
193+
code: "preview-canonical-origin-preservation-inconclusive",
194+
severity: "error",
195+
message: "The local Playground provider cannot preserve this declared canonical preview protocol.",
196+
details: { status: "inconclusive", canonicalOrigin: canonical.origin, supportedProtocols: ["http:"] },
197+
})
198+
return
199+
}
200+
201+
preview.effectiveOrigin = canonical.toString()
202+
preview.diagnostics.push({
203+
code: "preview-canonical-routed-origin",
204+
severity: "info",
205+
message: "The declared routed preview alias is the browser-visible origin.",
206+
details: { canonicalOrigin: canonical.origin, localProxyOrigin: new URL(preview.localOrigin).origin },
207+
})
208+
}
209+
157210
export function browserPreviewReadinessError(preview: BrowserProbePreviewRouting): Error | undefined {
158211
const diagnostic = preview.diagnostics.find((item) => item.severity === "error")
159212
if (!diagnostic) {
@@ -218,6 +271,7 @@ export function browserPreviewNetworkPolicy(args: string[], routeHosts: string[]
218271
recordExternal: strictBooleanArg(args, "record-external", false),
219272
stats: new Map(),
220273
routedRedirectEscapes: [],
274+
preserveRoutedOrigin: new URL(preview.effectiveOrigin).origin !== new URL(preview.localOrigin).origin && preview.effectiveMode === "local",
221275
}
222276
}
223277

@@ -321,28 +375,38 @@ async function routeBrowserPreviewNetwork(routePattern: (url: string, handler: (
321375
stat.requests += 1
322376
stat.external = !policy.firstPartyHosts.has(host)
323377

324-
if (policy.blockHosts.has(host) || (policy.mode === "block" && stat.external && !policy.allowHosts.has(host))) {
378+
if (policy.blockHosts.has(host)) {
325379
stat.blocked += 1
326380
await route.abort("blockedbyclient")
327381
return
328382
}
329383

330-
if (!policy.routeHosts.has(host)) {
331-
await route.continue()
384+
if (policy.routeHosts.has(host)) {
385+
stat.routed += 1
386+
if (policy.preserveRoutedOrigin) {
387+
await route.continue()
388+
return
389+
}
390+
const task = fulfillBrowserPreviewRoutedHost(route, requestUrl, policy, origin)
391+
tracker?.pending.add(task)
392+
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)
399+
}
332400
return
333401
}
334402

335-
stat.routed += 1
336-
const task = fulfillBrowserPreviewRoutedHost(route, requestUrl, policy, origin)
337-
tracker?.pending.add(task)
338-
try {
339-
await task
340-
} catch (error) {
341-
tracker?.errors.push(sanitizeBrowserPreviewRouteError(error))
342-
await route.abort("failed").catch(() => undefined)
343-
} finally {
344-
tracker?.pending.delete(task)
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")
406+
return
345407
}
408+
409+
await route.continue()
346410
})
347411
}
348412

0 commit comments

Comments
 (0)