Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions .github/workflows/agent-task-contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions packages/runtime-core/src/artifact-review.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,9 @@ export interface ArtifactReviewBrowserSummary {
localPreviewOrigin?: string
requestedPreviewOrigin?: string
effectivePreviewOrigin?: string
canonicalBrowserOrigin?: string
localProxyOrigin?: string
upstreamRuntimeOrigin?: string
finalUrl?: string
windowLocationOrigin?: string
viewport?: {
Expand Down
14 changes: 11 additions & 3 deletions packages/runtime-playground/src/browser-actions-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand All @@ -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) {
Expand Down Expand Up @@ -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()
Expand Down
6 changes: 6 additions & 0 deletions packages/runtime-playground/src/browser-artifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<string, ActorEvidence> = {}
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<Page, "goto"> }> = []
// Playground PHP commands share one runtime endpoint, so provision identities
Expand All @@ -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 })
Expand Down
102 changes: 83 additions & 19 deletions packages/runtime-playground/src/browser-preview-routing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface BrowserPreviewNetworkPolicy {
recordExternal: boolean
stats: Map<string, { requests: number; external: boolean; blocked: number; routed: number }>
routedRedirectEscapes: Array<{ rawOrigin: string; effectiveOrigin: string; reason: string }>
preserveRoutedOrigin: boolean
}

export interface BrowserPreviewNavigationDecision {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -97,23 +108,27 @@ 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)
},
authCookieUrls(targetUrls) {
return browserPreviewAuthCookieUrls(localPreviewOrigin, routedHosts, targetUrls)
},
contextOptions() {
return networkPolicy.preserveRoutedOrigin ? { proxy: { server: new URL(localPreviewOrigin).origin } } : {}
},
}
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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",
}
}

Expand Down Expand Up @@ -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()
})
}

Expand Down
Loading
Loading