diff --git a/.github/workflows/agent-task-contracts.yml b/.github/workflows/agent-task-contracts.yml index 8825fb5e5..fb397eaed 100644 --- a/.github/workflows/agent-task-contracts.yml +++ b/.github/workflows/agent-task-contracts.yml @@ -23,6 +23,14 @@ on: - "tests/redaction.test.ts" - "tests/production-boundary-enforcement.test.ts" - "tests/runtime-tool-policy.test.ts" + - "tests/browser-canonical-preview-origin.test.ts" + - "packages/runtime-playground/src/browser-actions-runner.ts" + - "packages/runtime-playground/src/browser-artifacts.ts" + - "packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts" + - "packages/runtime-playground/src/browser-preview-routing.ts" + - "packages/runtime-playground/src/browser-probe-runner.ts" + - "packages/runtime-playground/src/editor-command-runners.ts" + - "packages/runtime-playground/src/preview-server.ts" - "packages/runtime-playground/src/phpunit-command-handlers.ts" - "packages/runtime-playground/src/playground-cli-runner.ts" - "packages/runtime-playground/src/playground-wordpress-archive-cache.ts" @@ -56,6 +64,14 @@ on: - "tests/redaction.test.ts" - "tests/production-boundary-enforcement.test.ts" - "tests/runtime-tool-policy.test.ts" + - "tests/browser-canonical-preview-origin.test.ts" + - "packages/runtime-playground/src/browser-actions-runner.ts" + - "packages/runtime-playground/src/browser-artifacts.ts" + - "packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts" + - "packages/runtime-playground/src/browser-preview-routing.ts" + - "packages/runtime-playground/src/browser-probe-runner.ts" + - "packages/runtime-playground/src/editor-command-runners.ts" + - "packages/runtime-playground/src/preview-server.ts" - "packages/runtime-playground/src/phpunit-command-handlers.ts" - "packages/runtime-playground/src/playground-cli-runner.ts" - "packages/runtime-playground/src/playground-wordpress-archive-cache.ts" @@ -84,8 +100,16 @@ jobs: php-version: '8.2' tools: composer:v2 - run: npm ci + - name: Cache Playwright Chromium + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-chromium-${{ hashFiles('package-lock.json') }} + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium - run: npm run build - run: npm run test:agent-task-contracts + - run: npm run test:browser-canonical-preview-origin - run: npm run test:bounded-runtime-plan - run: npm run test:bounded-recipe-plan - run: npm run test:bounded-recipe-plan-integration diff --git a/package.json b/package.json index 0d0dd831b..25a2263d2 100644 --- a/package.json +++ b/package.json @@ -165,6 +165,7 @@ "test:artifact-reference-dtos": "tsx tests/artifact-reference-dtos.test.ts", "test:artifact-path-primitives": "tsx tests/artifact-path-primitives.test.ts", "test:browser-callback-materialization-contracts": "tsx tests/browser-callback-materialization-contracts.test.ts", + "test:browser-canonical-preview-origin": "tsx tests/browser-canonical-preview-origin.test.ts", "test:materialize-replay-package-command": "tsx tests/materialize-replay-package-command.test.ts", "test:source-package-compiler-primitives": "tsx tests/source-package-compiler-primitives.test.ts", "test:source-root-preparation": "tsx tests/source-root-preparation.test.ts", @@ -177,7 +178,7 @@ "test:native-agent-task-interruption": "node tests/execute-native-agent-task-interruption.test.mjs", "test:native-agent-task-playground-e2e": "tsx tests/execute-native-agent-task-playground-e2e.test.ts", "test:bench-command-step-behavior": "tsx tests/bench-command-step-behavior.test.ts", - "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", + "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", "test:browser-artifact-session": "tsx tests/browser-artifact-session.test.ts", "test:browser-environment-matrix": "tsx --test tests/browser-environment-matrix.test.ts tests/browser-environment-matrix.browser.test.ts", "test:browser-diagnostic-providers": "tsx tests/browser-diagnostic-providers.test.ts", diff --git a/packages/runtime-core/src/artifact-review.ts b/packages/runtime-core/src/artifact-review.ts index 415763312..6d4a404fc 100644 --- a/packages/runtime-core/src/artifact-review.ts +++ b/packages/runtime-core/src/artifact-review.ts @@ -92,6 +92,9 @@ export interface ArtifactReviewBrowserSummary { localPreviewOrigin?: string requestedPreviewOrigin?: string effectivePreviewOrigin?: string + canonicalBrowserOrigin?: string + localProxyOrigin?: string + upstreamRuntimeOrigin?: string finalUrl?: string windowLocationOrigin?: string viewport?: { diff --git a/packages/runtime-playground/src/browser-actions-runner.ts b/packages/runtime-playground/src/browser-actions-runner.ts index 64c085a11..d48e84462 100644 --- a/packages/runtime-playground/src/browser-actions-runner.ts +++ b/packages/runtime-playground/src/browser-actions-runner.ts @@ -12,7 +12,7 @@ import { browserAssertionsSummary, browserStepRecord, executeBrowserInteractionS import { browserCommandLivenessPolicy, isBrowserCommandLivenessError, withBrowserCommandLiveness } from "./browser-liveness.js" import { serializeBrowserError } from "./browser-metrics.js" import { executeBrowserObservationAssertion } from "./browser-observation-assertions.js" -import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js" +import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js" import { BROWSER_PROBE_STATE_INIT_SCRIPT, browserProbeReplayability, browserProbeViewport } from "./browser-probe.js" import { runBrowserProbeCommand, type BrowserProbeRunPlan } from "./browser-probe-runner.js" 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({ const startedAtMs = Date.now() const progress = createBrowserProbeProgressTracker(startedAt, 0) const browser = await launchChromiumBrowser() - const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl) + const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin) const { preview, networkPolicy } = topology let requestedUrl = initialUrl ? topology.resolveUrl(initialUrl) : preview.effectiveOrigin let finalUrl = requestedUrl @@ -144,11 +144,16 @@ export async function runBrowserActionsCommand({ let adaptiveExplorationSummary: BrowserArtifact["summary"]["adaptiveExploration"] | undefined try { + const previewReadinessError = browserPreviewReadinessError(preview) + if (previewReadinessError) { + throw previewReadinessError + } const context = browserPreviewNeedsContextRouting(networkPolicy) || !!storageStateImport ? await browser.newContext({ + ...topology.contextOptions(), ...(storageStateImport ? { storageState: storageStateImport.storageState } : {}), }) : null if (context) { - await routeBrowserPreviewContextNetwork(context, networkPolicy, preview.effectiveOrigin) + await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin) } const page = context ? await context.newPage() : await browser.newPage() if (onProgress) { @@ -409,6 +414,9 @@ export async function runBrowserActionsCommand({ } } } + } catch (error) { + pendingError = error instanceof Error ? error : new Error(String(error)) + errors.push(serializeBrowserError("probe-error", error)) } finally { await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs) await browser.close() diff --git a/packages/runtime-playground/src/browser-artifacts.ts b/packages/runtime-playground/src/browser-artifacts.ts index ef425b5c4..329f9edb2 100644 --- a/packages/runtime-playground/src/browser-artifacts.ts +++ b/packages/runtime-playground/src/browser-artifacts.ts @@ -17,6 +17,9 @@ export interface BrowserArtifactBase { localPreviewOrigin?: string requestedPreviewOrigin?: string effectivePreviewOrigin?: string + canonicalBrowserOrigin?: string + localProxyOrigin?: string + upstreamRuntimeOrigin?: string prePageScript?: BrowserProbeScriptMetadata files: BrowserArtifactFiles summary: BrowserArtifactSummary @@ -1043,6 +1046,9 @@ export function browserReviewSummary(probes: BrowserArtifact[]): ArtifactReviewB localPreviewOrigin: probe.localPreviewOrigin, requestedPreviewOrigin: probe.requestedPreviewOrigin, effectivePreviewOrigin: probe.effectivePreviewOrigin, + canonicalBrowserOrigin: probe.canonicalBrowserOrigin, + localProxyOrigin: probe.localProxyOrigin, + upstreamRuntimeOrigin: probe.upstreamRuntimeOrigin, finalUrl: probe.summary.finalUrl, windowLocationOrigin: probe.summary.windowLocationOrigin, viewport: probe.summary.viewport, diff --git a/packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts b/packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts index 86b59782f..066772f80 100644 --- a/packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts +++ b/packages/runtime-playground/src/browser-multi-actor-scenario-runner.ts @@ -6,7 +6,7 @@ import { BrowserCommandArtifactError } from "./browser-command-artifact-error.js import { attachBrowserCaptureListeners, launchChromiumBrowser, settleBrowserNetworkTasks } from "./browser-capture-session.js" import { executeBrowserInteractionStep } from "./browser-interactions.js" import { browserProbeReplayability } from "./browser-probe.js" -import { browserPreviewTopology, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js" +import { browserPreviewReadinessError, browserPreviewTopology, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js" import { installWordPressAdminAuthCookies } from "./browser-probe-support.js" import { bootstrapPhpCode } from "./php-bootstrap.js" import { assertPlaygroundResponseOk, type PlaygroundRunResponse } from "./playground-command-errors.js" @@ -28,13 +28,18 @@ export async function runBrowserMultiActorScenarioCommand(input: { // Traces are always retained for replay, even when callers narrow display captures. const captures = new Set([...(scenario.captures ?? ["steps", "console", "errors", "network", "screenshot"]), "trace"]) const artifacts = new BrowserArtifactSession(artifactRoot, "files/browser", { source: "wordpress.browser-scenario", operation: "browser-multi-actor-scenario" }) - const topology = browserPreviewTopology([], runtimeSpec, server.serverUrl) + const routeHost = runtimeSpec.preview?.siteUrl ? new URL(runtimeSpec.preview.siteUrl).hostname : "" + const topology = browserPreviewTopology(routeHost ? [`route-host=${routeHost}`] : [], runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin) const browser = await launchChromiumBrowser() const evidence: Record = {} let result: BrowserMultiActorScenarioResult | undefined let failure: Error | undefined try { + const previewReadinessError = browserPreviewReadinessError(topology.preview) + if (previewReadinessError) { + throw previewReadinessError + } const clientEntries: Array<[string, BrowserMultiActorClient]> = [] const actorPages: Array<{ actor: string; page: Pick }> = [] // Playground PHP commands share one runtime endpoint, so provision identities @@ -43,8 +48,8 @@ export async function runBrowserMultiActorScenarioCommand(input: { const session = wordpressUserSessionFromCommandArgs([`session=${actor.userSession}`], runtimeSpec) if (!session) throw new Error(`Actor ${actor.name} requires user session ${actor.userSession}`) const userId = await actorUserId(actor.name, session.user.userId, session.user, runtimeSpec, runPlaygroundCommand, server) - const context = await browser.newContext() - await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.preview.effectiveOrigin) + const context = await browser.newContext(topology.contextOptions()) + await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.origins.localProxyOrigin) const page = await context.newPage() await context.tracing.start({ screenshots: true, snapshots: true }) await installWordPressAdminAuthCookies({ command: "wordpress.browser-scenario", cookieUrls: topology.authCookieUrls([topology.resolveUrl(scenario.url)]), page, runPlaygroundCommand, runtimeSpec, server, userId }) diff --git a/packages/runtime-playground/src/browser-preview-routing.ts b/packages/runtime-playground/src/browser-preview-routing.ts index 86a98c61a..acc301a3f 100644 --- a/packages/runtime-playground/src/browser-preview-routing.ts +++ b/packages/runtime-playground/src/browser-preview-routing.ts @@ -17,6 +17,7 @@ export interface BrowserPreviewNetworkPolicy { recordExternal: boolean stats: Map routedRedirectEscapes: Array<{ rawOrigin: string; effectiveOrigin: string; reason: string }> + preserveRoutedOrigin: boolean } export interface BrowserPreviewNavigationDecision { @@ -44,10 +45,20 @@ export interface BrowserPreviewTopology { preview: BrowserProbePreviewRouting networkPolicy: BrowserPreviewNetworkPolicy routedHosts: string[] - origins: { localPreviewOrigin: string; requestedPreviewOrigin?: string; effectivePreviewOrigin: string } + origins: BrowserPreviewOrigins navigationScope: BrowserPreviewNavigationScope resolveUrl(pathOrUrl: string): string authCookieUrls(targetUrls: string[]): string[] + contextOptions(): { proxy?: { server: string } } +} + +export interface BrowserPreviewOrigins { + localPreviewOrigin: string + requestedPreviewOrigin?: string + effectivePreviewOrigin: string + canonicalBrowserOrigin: string + localProxyOrigin: string + upstreamRuntimeOrigin?: string } export interface BrowserPreviewRouteTracker { @@ -97,16 +108,17 @@ export function browserPreviewRouting(args: string[], runtimeSpec: RuntimeCreate } } -export function browserPreviewTopology(args: string[], runtimeSpec: RuntimeCreateSpec | undefined, localPreviewOrigin: string): BrowserPreviewTopology { - const preview = browserPreviewRouting(args, runtimeSpec, localPreviewOrigin) +export function browserPreviewTopology(args: string[], runtimeSpec: RuntimeCreateSpec | undefined, localPreviewOrigin: string, upstreamRuntimeOrigin?: string): BrowserPreviewTopology { const routedHosts = commaListArg(args, "route-host") + const preview = browserPreviewRouting(args, runtimeSpec, localPreviewOrigin) + applyCanonicalRoutedPreviewOrigin(preview, runtimeSpec?.preview?.siteUrl, routedHosts) const networkPolicy = browserPreviewNetworkPolicy(args, routedHosts, preview) return { preview, networkPolicy, routedHosts, - origins: browserPreviewOrigins(preview), + origins: browserPreviewOrigins(preview, upstreamRuntimeOrigin), navigationScope: browserPreviewNavigationScope(preview.effectiveOrigin, networkPolicy), resolveUrl(pathOrUrl) { return resolveBrowserPreviewUrl(pathOrUrl, preview.effectiveOrigin) @@ -114,6 +126,9 @@ export function browserPreviewTopology(args: string[], runtimeSpec: RuntimeCreat authCookieUrls(targetUrls) { return browserPreviewAuthCookieUrls(localPreviewOrigin, routedHosts, targetUrls) }, + contextOptions() { + return networkPolicy.preserveRoutedOrigin ? { proxy: { server: new URL(localPreviewOrigin).origin } } : {} + }, } } @@ -146,14 +161,52 @@ export function browserPreviewNavigationScope(effectivePreviewOrigin: string, po } } -export function browserPreviewOrigins(preview: BrowserProbePreviewRouting): { localPreviewOrigin: string; requestedPreviewOrigin?: string; effectivePreviewOrigin: string } { +export function browserPreviewOrigins(preview: BrowserProbePreviewRouting, upstreamRuntimeOrigin?: string): BrowserPreviewOrigins { return { localPreviewOrigin: preview.localOrigin, - requestedPreviewOrigin: preview.publicOrigin, + ...(preview.publicOrigin ? { requestedPreviewOrigin: preview.publicOrigin } : {}), effectivePreviewOrigin: preview.effectiveOrigin, + canonicalBrowserOrigin: new URL(preview.effectiveOrigin).origin, + localProxyOrigin: new URL(preview.localOrigin).origin, + ...(upstreamRuntimeOrigin ? { upstreamRuntimeOrigin: new URL(upstreamRuntimeOrigin).origin } : {}), } } +function applyCanonicalRoutedPreviewOrigin(preview: BrowserProbePreviewRouting, siteUrl: string | undefined, routedHosts: string[]): void { + if (preview.effectiveMode !== "local" || !siteUrl) { + return + } + + let canonical: URL + try { + canonical = new URL(siteUrl) + } catch { + return + } + const host = normalizeBrowserPreviewHost(canonical.hostname) + if (!routedHosts.map(normalizeBrowserPreviewHost).includes(host)) { + return + } + + if (canonical.protocol !== "http:") { + preview.diagnostics.push({ + code: "preview-canonical-origin-preservation-inconclusive", + severity: "error", + message: "The local Playground provider cannot preserve this declared canonical preview protocol.", + details: { status: "inconclusive", canonicalOrigin: canonical.origin, supportedProtocols: ["http:"] }, + }) + return + } + + preview.effectiveOrigin = canonical.toString() + preview.diagnostics.push({ + code: "preview-canonical-routed-origin", + severity: "info", + message: "The declared routed preview alias is the browser-visible origin.", + details: { canonicalOrigin: canonical.origin, localProxyOrigin: new URL(preview.localOrigin).origin }, + }) +} + export function browserPreviewReadinessError(preview: BrowserProbePreviewRouting): Error | undefined { const diagnostic = preview.diagnostics.find((item) => item.severity === "error") if (!diagnostic) { @@ -218,6 +271,7 @@ export function browserPreviewNetworkPolicy(args: string[], routeHosts: string[] recordExternal: strictBooleanArg(args, "record-external", false), stats: new Map(), routedRedirectEscapes: [], + preserveRoutedOrigin: new URL(preview.effectiveOrigin).origin !== new URL(preview.localOrigin).origin && preview.effectiveMode === "local", } } @@ -321,28 +375,38 @@ async function routeBrowserPreviewNetwork(routePattern: (url: string, handler: ( stat.requests += 1 stat.external = !policy.firstPartyHosts.has(host) - if (policy.blockHosts.has(host) || (policy.mode === "block" && stat.external && !policy.allowHosts.has(host))) { + if (policy.blockHosts.has(host)) { stat.blocked += 1 await route.abort("blockedbyclient") return } - if (!policy.routeHosts.has(host)) { - await route.continue() + if (policy.routeHosts.has(host)) { + stat.routed += 1 + if (policy.preserveRoutedOrigin) { + await route.continue() + return + } + const task = fulfillBrowserPreviewRoutedHost(route, requestUrl, policy, origin) + tracker?.pending.add(task) + try { + await task + } catch (error) { + tracker?.errors.push(sanitizeBrowserPreviewRouteError(error)) + await route.abort("failed").catch(() => undefined) + } finally { + tracker?.pending.delete(task) + } return } - stat.routed += 1 - const task = fulfillBrowserPreviewRoutedHost(route, requestUrl, policy, origin) - tracker?.pending.add(task) - try { - await task - } catch (error) { - tracker?.errors.push(sanitizeBrowserPreviewRouteError(error)) - await route.abort("failed").catch(() => undefined) - } finally { - tracker?.pending.delete(task) + if (policy.preserveRoutedOrigin || (policy.mode === "block" && stat.external && !policy.allowHosts.has(host)) || (request.resourceType() === "document" && stat.external)) { + stat.blocked += 1 + await route.abort("blockedbyclient") + return } + + await route.continue() }) } diff --git a/packages/runtime-playground/src/browser-probe-runner.ts b/packages/runtime-playground/src/browser-probe-runner.ts index 4528a97de..829e0e9e3 100644 --- a/packages/runtime-playground/src/browser-probe-runner.ts +++ b/packages/runtime-playground/src/browser-probe-runner.ts @@ -242,7 +242,7 @@ export async function runSingleBrowserProbeCommand({ const activeDiagnosticProviders = runPlan.diagnosticProviders ?? diagnosticProviders ?? [] const captureSelection = browserProbeCaptureSelection(capture, assertions) const prePageScriptMetadata = prePageScript ? browserProbeScriptMetadata(prePageScript) : undefined - const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl) + const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin) const { preview, networkPolicy } = topology const routeTracker = createBrowserPreviewRouteTracker() const targetUrl = topology.resolveUrl(runPlan.url) @@ -304,6 +304,7 @@ export async function runSingleBrowserProbeCommand({ const contextPermissions = browserProbeContextPermissions(requestedContext) context = browserPreviewNeedsContextRouting(networkPolicy) || !!storageStateImport || requestedContext.device || requestedContext.geolocation || requestedContext.locale || requestedContext.timezone || requestedContext.userAgent || contextPermissions.length > 0 ? await browser.newContext({ + ...topology.contextOptions(), ...(deviceProfile ?? {}), ...(storageStateImport ? { storageState: storageStateImport.storageState } : {}), ...(requestedContext.locale ? { locale: requestedContext.locale } : {}), @@ -316,7 +317,7 @@ export async function runSingleBrowserProbeCommand({ await context.grantPermissions(contextPermissions) } if (context && browserPreviewNeedsContextRouting(networkPolicy)) { - await routeBrowserPreviewContextNetwork(context, networkPolicy, preview.effectiveOrigin, routeTracker) + await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin, routeTracker) } page = context ? await context.newPage() : await browser.newPage() if (requestedContext.geolocation?.permission === "denied") geolocationPermissionCleanup = await applyPlaywrightGeolocationPermission(page, "denied") @@ -343,7 +344,7 @@ export async function runSingleBrowserProbeCommand({ await applyBrowserProbeThrottleProfile(page, throttleProfile) } if (!context && browserPreviewNeedsContextRouting(networkPolicy)) { - await routeBrowserPreviewPageNetwork(page, networkPolicy, preview.effectiveOrigin, routeTracker) + await routeBrowserPreviewPageNetwork(page, networkPolicy, topology.origins.localProxyOrigin, routeTracker) } await page.addInitScript(BROWSER_PROBE_STATE_INIT_SCRIPT) if (lifecycleSelectors.length > 0) { diff --git a/packages/runtime-playground/src/editor-command-runners.ts b/packages/runtime-playground/src/editor-command-runners.ts index 751af6d0e..58ed21b62 100644 --- a/packages/runtime-playground/src/editor-command-runners.ts +++ b/packages/runtime-playground/src/editor-command-runners.ts @@ -6,7 +6,7 @@ import { BrowserCommandArtifactError } from "./browser-command-artifact-error.js import type { BrowserArtifact, BrowserArtifactFiles, BrowserArtifactSummary, BrowserEditorCanvasProbeDiagnostic, BrowserEditorCanvasProbeSummary, BrowserEditorCanvasSelectorGroupSummary, BrowserEditorCanvasSelectorSummary, BrowserEditorReadinessSummary, BrowserEditorSaveSummary, BrowserEditorValidateBlocksSummary, BrowserEditorValiditySummary, BrowserProbeAuthSummary, BrowserProbeErrorRecord, BrowserProbeViewport, BrowserStepRecord } from "./browser-artifacts.js" import { attachBrowserCaptureListeners, launchChromiumBrowser } from "./browser-capture-session.js" import { browserStepRecord } from "./browser-interactions.js" -import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewOrigins, browserPreviewReadinessError, browserPreviewRouting, browserPreviewSecureContextError, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js" +import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewSecureContextError, browserPreviewTopology, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js" import { browserProbeReplayability, browserProbeViewport } from "./browser-probe.js" import { argValue, commaListArg, durationArg, jsonArrayArg } from "./commands.js" import { DEFAULT_EDITOR_WAIT_SELECTOR, editorActionStepsFromArgs, editorOpenTargetFromArgs, editorValidateContentFromArgs, editorValidateProviderFromArgs, resolveEditorOpenTarget, type EditorActionStep, type EditorBlockSpec, type EditorBlockTarget } from "./editor-actions.js" @@ -57,9 +57,10 @@ export async function runEditorCanvasProbeCommand({ const blockSelector = argValue(args, "block-selector")?.trim() || EDITOR_CANVAS_DEFAULT_BLOCK_SELECTOR const timeoutMs = editorCanvasTimeoutMs(args) const selectorGroups = editorCanvasSelectorGroups(args, layoutSelector, blockSelector) - const preview = browserPreviewRouting(args, runtimeSpec, server.serverUrl) - const previewOrigins = browserPreviewOrigins(preview) - const targetUrl = resolveBrowserPreviewUrl(urlArg, preview.effectiveOrigin) + const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin) + const { preview, networkPolicy } = topology + const previewOrigins = topology.origins + const targetUrl = topology.resolveUrl(urlArg) const artifactSession = new BrowserArtifactSession(artifactRoot, "files/browser", { source: "wordpress.editor-canvas-probe", operation: "editor-canvas-probe" }) const screenshotPath = artifactSession.absolutePath("editor-canvas-screenshot.png") const startedAt = now() @@ -79,7 +80,11 @@ export async function runEditorCanvasProbeCommand({ throw previewReadinessError } - const page = await browser.newPage() + const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext(topology.contextOptions()) : null + if (context) { + await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin) + } + const page = context ? await context.newPage() : await browser.newPage() viewport = await browserProbeViewport(page) attachBrowserCaptureListeners({ captureConsole: false, @@ -520,7 +525,7 @@ export async function runEditorOpenCommand({ } const waitTimeoutMs = durationArg(args, "wait-timeout", BROWSER_STEP_DEFAULT_TIMEOUT_MS) - const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl) + const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin) const { preview, networkPolicy } = topology const targetUrl = topology.resolveUrl(target.url) const artifactPathPrefix = editorOpenArtifactPathPrefixFromArgs(args) @@ -545,9 +550,13 @@ export async function runEditorOpenCommand({ let artifact: BrowserArtifact | undefined try { - const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext() : null + const previewReadinessError = browserPreviewReadinessError(preview) + if (previewReadinessError) { + throw previewReadinessError + } + const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext(topology.contextOptions()) : null if (context) { - await routeBrowserPreviewContextNetwork(context, networkPolicy, preview.effectiveOrigin) + await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin) } const page = context ? await context.newPage() : await browser.newPage() authSummary = await installWordPressAdminAuthCookies({ command: "wordpress.editor-open", cookieUrls: topology.authCookieUrls([targetUrl]), page, runPlaygroundCommand, runtimeSpec, server, userId: 1 }) @@ -621,6 +630,9 @@ export async function runEditorOpenCommand({ }) screenshotSha256 = await fileSha256(screenshotPath) } + } catch (error) { + pendingError = error instanceof Error ? error : new Error(String(error)) + errors.push(serializeBrowserError("probe-error", error)) } finally { await browser.close() if (capture.has("steps")) { @@ -803,7 +815,7 @@ export async function runEditorActionsCommand({ const waitTimeoutMs = durationArg(args, "wait-timeout", BROWSER_STEP_DEFAULT_TIMEOUT_MS) const stepTimeoutMs = durationArg(args, "step-timeout", BROWSER_STEP_DEFAULT_TIMEOUT_MS) const totalTimeoutMs = durationArg(args, "timeout", BROWSER_SCRIPT_DEFAULT_TIMEOUT_MS) - const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl) + const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin) const { preview, networkPolicy } = topology const targetUrl = topology.resolveUrl(target.url) const artifactSession = new BrowserArtifactSession(artifactRoot, "files/browser", { source: "wordpress.editor-actions", operation: "editor-actions" }) @@ -828,9 +840,13 @@ export async function runEditorActionsCommand({ let artifact: BrowserArtifact | undefined try { - const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext() : null + const previewReadinessError = browserPreviewReadinessError(preview) + if (previewReadinessError) { + throw previewReadinessError + } + const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext(topology.contextOptions()) : null if (context) { - await routeBrowserPreviewContextNetwork(context, networkPolicy, preview.effectiveOrigin) + await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin) } const page = context ? await context.newPage() : await browser.newPage() authSummary = await installWordPressAdminAuthCookies({ command: "wordpress.editor-actions", cookieUrls: topology.authCookieUrls([targetUrl]), page, runPlaygroundCommand, runtimeSpec, server, userId: 1 }) @@ -933,6 +949,9 @@ export async function runEditorActionsCommand({ await artifactSession.writeGenerated("screenshot", "editor-action-screenshot.png", (path) => page.screenshot({ path, fullPage: true }).then(() => undefined)) screenshotSha256 = await fileSha256(screenshotPath) } + } catch (error) { + pendingError = error instanceof Error ? error : new Error(String(error)) + errors.push(serializeBrowserError("probe-error", error)) } finally { await browser.close() if (capture.has("steps")) { @@ -1910,7 +1929,7 @@ export async function runEditorValidateBlocksCommand({ const content = await editorValidateContentFromArgs(args) const provider = editorValidateProviderFromArgs(args) const waitTimeoutMs = durationArg(args, "wait-timeout", EDITOR_VALIDATE_BLOCKS_READY_TIMEOUT_MS) - const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl) + const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin) const { preview, networkPolicy } = topology const targetUrl = topology.resolveUrl(target.url) const artifactSession = new BrowserArtifactSession(artifactRoot, "files/browser", { source: "wordpress.editor-validate-blocks", operation: "editor-validate-blocks" }) @@ -1926,9 +1945,13 @@ export async function runEditorValidateBlocksCommand({ let artifact: BrowserArtifact | undefined try { - const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext() : null + const previewReadinessError = browserPreviewReadinessError(preview) + if (previewReadinessError) { + throw previewReadinessError + } + const context = browserPreviewNeedsContextRouting(networkPolicy) ? await browser.newContext(topology.contextOptions()) : null if (context) { - await routeBrowserPreviewContextNetwork(context, networkPolicy, preview.effectiveOrigin) + await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin) } const page = context ? await context.newPage() : await browser.newPage() authSummary = await installWordPressAdminAuthCookies({ command: "wordpress.editor-validate-blocks", cookieUrls: topology.authCookieUrls([targetUrl]), page, runPlaygroundCommand, runtimeSpec, server, userId: 1 }) diff --git a/packages/runtime-playground/src/preview-server.ts b/packages/runtime-playground/src/preview-server.ts index 055d6f129..668c10558 100644 --- a/packages/runtime-playground/src/preview-server.ts +++ b/packages/runtime-playground/src/preview-server.ts @@ -161,6 +161,7 @@ function createPreviewRouteRegistry(): InternalPreviewRouteRegistry { function proxyPreviewRequest(target: URL, incoming: IncomingMessage, outgoing: ServerResponse): Promise { return new Promise((resolve) => { + const requestTarget = previewProxyRequestTarget(incoming) let settled = false let targetResponse: IncomingMessage | undefined const settle = () => { @@ -182,12 +183,12 @@ function proxyPreviewRequest(target: URL, incoming: IncomingMessage, outgoing: S hostname: target.hostname, port: target.port, method: incoming.method, - path: incoming.url ?? "/", - headers: proxyRequestHeaders(incoming.headers), + path: requestTarget.path, + headers: proxyRequestHeaders(incoming.headers, requestTarget), }, (response) => { targetResponse = response - outgoing.writeHead(response.statusCode ?? 502, response.statusMessage, proxyResponseHeaders(response.headers)) + outgoing.writeHead(response.statusCode ?? 502, response.statusMessage, proxyResponseHeaders(response.headers, requestTarget, target)) response.on("error", (error) => { outgoing.destroy(error) settle() @@ -211,6 +212,33 @@ function proxyPreviewRequest(target: URL, incoming: IncomingMessage, outgoing: S }) } +interface PreviewProxyRequestTarget { + host: string + path: string + port: string + protocol: "http:" | "https:" +} + +function previewProxyRequestTarget(incoming: IncomingMessage): PreviewProxyRequestTarget { + const rawUrl = incoming.url ?? "/" + try { + const url = new URL(rawUrl) + if (url.protocol === "http:" || url.protocol === "https:") { + return { + host: url.host, + path: `${url.pathname}${url.search}`, + port: url.port || (url.protocol === "https:" ? "443" : "80"), + protocol: url.protocol, + } + } + } catch { + // Origin-form requests use the proxy listener's HTTP authority. + } + const host = incoming.headers.host ?? "localhost" + const authority = new URL(`http://${host}`) + return { host: authority.host, path: rawUrl, port: authority.port || "80", protocol: "http:" } +} + function createPreviewProxyQueue(): (task: () => Promise) => Promise { let active = false const pending: Array<() => void> = [] @@ -267,21 +295,45 @@ function formatPreviewHost(host: string): string { return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host } -function proxyRequestHeaders(headers: IncomingHttpHeaders): IncomingHttpHeaders { +function proxyRequestHeaders(headers: IncomingHttpHeaders, requestTarget: PreviewProxyRequestTarget): IncomingHttpHeaders { const forwarded = { ...headers } delete forwarded.connection delete forwarded["transfer-encoding"] + delete forwarded.forwarded + delete forwarded["x-forwarded-for"] + delete forwarded["x-forwarded-host"] + delete forwarded["x-forwarded-port"] + delete forwarded["x-forwarded-proto"] return { ...forwarded, + host: requestTarget.host, + "x-forwarded-host": requestTarget.host, + "x-forwarded-port": requestTarget.port, + "x-forwarded-proto": requestTarget.protocol.slice(0, -1), } } -function proxyResponseHeaders(headers: IncomingHttpHeaders): IncomingHttpHeaders { +function proxyResponseHeaders(headers: IncomingHttpHeaders, requestTarget: PreviewProxyRequestTarget, target: URL): IncomingHttpHeaders { const forwarded = { ...headers } delete forwarded.connection delete forwarded["transfer-encoding"] + if (typeof forwarded.location === "string") { + try { + const location = new URL(forwarded.location, target) + if (location.origin === target.origin) { + location.protocol = requestTarget.protocol + const visible = new URL(`${requestTarget.protocol}//${requestTarget.host}`) + location.hostname = visible.hostname + location.port = visible.port + forwarded.location = location.toString() + } + } catch { + // Preserve malformed upstream locations for the browser to diagnose. + } + } + return forwarded } diff --git a/tests/browser-callback-materialization-contracts.test.ts b/tests/browser-callback-materialization-contracts.test.ts index d09c84bed..0d60a3dab 100644 --- a/tests/browser-callback-materialization-contracts.test.ts +++ b/tests/browser-callback-materialization-contracts.test.ts @@ -21,7 +21,8 @@ import { trustedBrowserSessionOrigins, verifyBrowserCallbackSignature, } from "../packages/runtime-core/src/index.js" -import { browserPreviewAuthCookieUrls, browserPreviewTopology } from "../packages/runtime-playground/src/browser-preview-routing.js" +import { browserPreviewAuthCookieUrls, browserPreviewReadinessError, browserPreviewTopology } from "../packages/runtime-playground/src/browser-preview-routing.js" +import { browserReviewSummary, type BrowserArtifact } from "../packages/runtime-playground/src/browser-artifacts.js" import { closeHttpServer, listenLocalHttpServer, withPreviewProxy, type PlaygroundCliServer } from "../packages/runtime-playground/src/preview-server.js" const wait = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)) @@ -104,6 +105,8 @@ assert.deepEqual(topology.origins, { localPreviewOrigin: "http://127.0.0.1:9400", requestedPreviewOrigin: "https://example.test/site", effectivePreviewOrigin: "https://example.test/site", + canonicalBrowserOrigin: "https://example.test", + localProxyOrigin: "http://127.0.0.1:9400", }) assert.deepEqual(topology.authCookieUrls(["https://example.test/wp-admin/"]), [ "http://127.0.0.1/", @@ -136,6 +139,54 @@ assert.deepEqual(topology.navigationScope.resolve("https://cdn.example.test/asse assert.equal(topology.navigationScope.resolve("https://outside.example/", "https://example.test/").reason, "host-not-routed-to-preview") assert.deepEqual(browserPreviewAuthCookieUrls("http://localhost:9400", ["PUBLIC.example.test"], ["https://public.example.test/wp-admin/"]), ["http://localhost/", "https://public.example.test/"]) +const canonicalTopology = browserPreviewTopology( + ["route-host=localhost"], + { preview: { siteUrl: "http://localhost/events/" } }, + "http://127.0.0.1:9400", + "http://127.0.0.1:9500", +) +assert.equal(canonicalTopology.preview.effectiveOrigin, "http://localhost/events/") +assert.equal(canonicalTopology.resolveUrl("/events/near-me/"), "http://localhost/events/near-me/") +assert.deepEqual(canonicalTopology.origins, { + localPreviewOrigin: "http://127.0.0.1:9400", + effectivePreviewOrigin: "http://localhost/events/", + canonicalBrowserOrigin: "http://localhost", + localProxyOrigin: "http://127.0.0.1:9400", + upstreamRuntimeOrigin: "http://127.0.0.1:9500", +}) +assert.equal(canonicalTopology.preview.diagnostics[0]?.code, "preview-canonical-routed-origin") +const canonicalReview = browserReviewSummary([{ + artifactType: "probe", + requestedUrl: "http://localhost/events/", + url: "http://localhost/events/", + preview: canonicalTopology.preview, + ...canonicalTopology.origins, + files: { summary: "files/browser/summary.json" }, + summary: { consoleMessages: 0, errors: 0, htmlSnapshot: false, networkEvents: 0, replayability: "diagnostic-only", screenshot: false, viewport: null }, +} satisfies BrowserArtifact]) +assert.deepEqual(canonicalReview?.probes[0] && { + canonicalBrowserOrigin: canonicalReview.probes[0].canonicalBrowserOrigin, + localProxyOrigin: canonicalReview.probes[0].localProxyOrigin, + upstreamRuntimeOrigin: canonicalReview.probes[0].upstreamRuntimeOrigin, +}, { + canonicalBrowserOrigin: "http://localhost", + localProxyOrigin: "http://127.0.0.1:9400", + upstreamRuntimeOrigin: "http://127.0.0.1:9500", +}) +const unsupportedCanonicalTopology = browserPreviewTopology( + ["route-host=secure.example.test"], + { preview: { siteUrl: "https://secure.example.test/" } }, + "http://127.0.0.1:9400", +) +assert.equal(unsupportedCanonicalTopology.preview.effectiveOrigin, "http://127.0.0.1:9400") +assert.deepEqual(unsupportedCanonicalTopology.preview.diagnostics[0], { + code: "preview-canonical-origin-preservation-inconclusive", + severity: "error", + message: "The local Playground provider cannot preserve this declared canonical preview protocol.", + details: { status: "inconclusive", canonicalOrigin: "https://secure.example.test", supportedProtocols: ["http:"] }, +}) +assert.match(browserPreviewReadinessError(unsupportedCanonicalTopology.preview)?.message ?? "", /cannot preserve/) + let activeUpstreamRequests = 0 let maxActiveUpstreamRequests = 0 const targetServer = createServer(async (_request, response) => { diff --git a/tests/browser-canonical-preview-origin.test.ts b/tests/browser-canonical-preview-origin.test.ts new file mode 100644 index 000000000..40d955a99 --- /dev/null +++ b/tests/browser-canonical-preview-origin.test.ts @@ -0,0 +1,124 @@ +import assert from "node:assert/strict" +import { createServer } from "node:http" +import { launchChromiumBrowser } from "../packages/runtime-playground/src/browser-capture-session.js" +import { browserPreviewNetworkPolicySummary, browserPreviewTopology, routeBrowserPreviewContextNetwork } from "../packages/runtime-playground/src/browser-preview-routing.js" +import { closeHttpServer, listenLocalHttpServer, withPreviewProxy, type PlaygroundCliServer } from "../packages/runtime-playground/src/preview-server.js" + +const requests: Array<{ host?: string; method?: string; url?: string; body: string; forwardedHost?: string; forwardedPort?: string; forwardedProto?: string }> = [] +const upstream = createServer((request, response) => { + let body = "" + request.on("data", (chunk) => { body += chunk.toString() }) + request.on("end", () => { + requests.push({ + host: request.headers.host, + method: request.method, + url: request.url, + body, + forwardedHost: request.headers["x-forwarded-host"] as string | undefined, + forwardedPort: request.headers["x-forwarded-port"] as string | undefined, + forwardedProto: request.headers["x-forwarded-proto"] as string | undefined, + }) + if (request.url === "/events/redirect/") { + response.writeHead(302, { location: "http://localhost/events/final/" }) + response.end() + return + } + if (request.url === "/events/local-redirect/") { + response.writeHead(302, { location: `${upstreamUrl}/events/final/` }) + response.end() + return + } + if (request.url === "/events/external-redirect/") { + response.writeHead(302, { location: "http://undeclared.example/escape/" }) + response.end() + return + } + + response.writeHead(200, { + "content-type": "text/html; charset=utf-8", + "set-cookie": "canonical_cookie=present; Path=/events/; SameSite=Lax", + }) + response.end(` + Canonical preview +
+ Subsite + `) + }) +}) +const upstreamUrl = await listenLocalHttpServer(upstream) +const proxy = await withPreviewProxy({ + playground: { async run() { return { text: "" } } }, + serverUrl: upstreamUrl, + async [Symbol.asyncDispose]() {}, +} satisfies PlaygroundCliServer, 0) +const topology = browserPreviewTopology( + ["route-host=localhost"], + { preview: { siteUrl: "http://localhost/events/" } }, + proxy.serverUrl, + proxy.previewProxyDiagnostics?.targetOrigin, +) +const browser = await launchChromiumBrowser() + +try { + const context = await browser.newContext({ + ...topology.contextOptions(), + extraHTTPHeaders: { + "x-forwarded-host": "spoofed.example", + "x-forwarded-port": "443", + "x-forwarded-proto": "https", + }, + }) + await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.origins.localProxyOrigin) + const page = await context.newPage() + + await page.goto(topology.resolveUrl("/events/"), { waitUntil: "load" }) + assert.equal(new URL(page.url()).origin, "http://localhost") + assert(requests.some((request) => request.url === "/events/" && request.host === "localhost" && request.forwardedHost === "localhost" && request.forwardedPort === "80" && request.forwardedProto === "http")) + assert.deepEqual(await page.evaluate(() => { + history.pushState({}, "", "http://localhost/events/pushed/") + history.replaceState({}, "", "http://localhost/events/replaced/") + return { + cookie: document.cookie, + origin: location.origin, + pathname: location.pathname, + storage: localStorage.getItem("canonical-storage"), + } + }), { + cookie: "canonical_cookie=present", + origin: "http://localhost", + pathname: "/events/replaced/", + storage: "present", + }) + + await page.goto("http://localhost/events/") + await Promise.all([page.waitForURL("http://localhost/events/form/"), page.click("#canonical-form button")]) + assert(requests.some((request) => request.host === "localhost" && request.method === "POST" && request.url === "/events/form/" && request.body === "value=canonical")) + + await page.goto("http://localhost/events/redirect/") + assert.equal(page.url(), "http://localhost/events/final/") + await page.goto("http://localhost/events/local-redirect/") + assert.equal(page.url(), "http://localhost/events/final/") + + await page.goto("http://localhost/events/") + await Promise.all([page.waitForURL("http://localhost/events/subsite/"), page.click("#subsite")]) + assert.equal(page.url(), "http://localhost/events/subsite/") + + await assert.rejects(page.goto("http://undeclared.example/escape/")) + await assert.rejects(page.goto("http://localhost/events/external-redirect/")) + const policyEvidence = browserPreviewNetworkPolicySummary(topology.networkPolicy) + assert((policyEvidence.hosts["undeclared.example"]?.blocked ?? 0) >= 1, JSON.stringify(policyEvidence)) + assert(policyEvidence.blockedRequests >= 1) + + assert.deepEqual(topology.origins, { + localPreviewOrigin: proxy.serverUrl, + effectivePreviewOrigin: "http://localhost/events/", + canonicalBrowserOrigin: "http://localhost", + localProxyOrigin: new URL(proxy.serverUrl).origin, + upstreamRuntimeOrigin: new URL(upstreamUrl).origin, + }) + await context.close() +} finally { + await browser.close() + await proxy[Symbol.asyncDispose]() + await closeHttpServer(upstream) +}