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
8 changes: 8 additions & 0 deletions .github/workflows/agent-task-contracts.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ on:
- "fixtures/agent-task-runtime-paths-run-29305012941.json"
- "package-lock.json"
- "package.json"
- "packages/runtime-playground/src/**"
- "tests/browser-*.test.ts"
- "tests/editor-*.test.ts"
- "tests/agent-task-*.test.ts"
- "tests/runtime-sources-materialization.test.ts"
- "tests/runtime-sources-playground-integration.test.ts"
Expand Down Expand Up @@ -58,6 +61,9 @@ on:
- "fixtures/agent-task-runtime-paths-run-29305012941.json"
- "package-lock.json"
- "package.json"
- "packages/runtime-playground/src/**"
- "tests/browser-*.test.ts"
- "tests/editor-*.test.ts"
- "tests/agent-task-*.test.ts"
- "tests/runtime-sources-materialization.test.ts"
- "tests/runtime-sources-playground-integration.test.ts"
Expand Down Expand Up @@ -129,6 +135,8 @@ jobs:
- run: npm run test:trusted-apply-artifact-channel
- run: npm run test:runtime-command-artifact-bounds
- run: npm run test:redaction
- run: npm run test:browser-preview-routing
- run: npm run test:browser-routed-command-security
- run: npm run test:production-boundary-enforcement
- run: npm run test:runtime-tool-policy

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@
"smoke": "tsx scripts/run-smoke.ts",
"test:redaction": "tsx tests/redaction.test.ts",
"test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts",
"test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts",
"test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit",
"test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts",
"test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts",
Expand Down
53 changes: 31 additions & 22 deletions packages/runtime-playground/src/browser-actions-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ 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, browserPreviewReadinessError, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewTopology, closeBrowserAndDrainPreviewRoutes, createBrowserPreviewRouteTracker, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
import { browserCommandResult } from "./browser-result-sanitization.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 @@ -133,6 +134,7 @@ export async function runBrowserActionsCommand({
const browser = session?.browser ?? await launchChromiumBrowser()
const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin)
const { preview, networkPolicy } = topology
const routeTracker = session?.routeTracker ?? createBrowserPreviewRouteTracker()
let requestedUrl = initialUrl ? topology.resolveUrl(initialUrl) : preview.effectiveOrigin
let finalUrl = requestedUrl
let htmlSha256: string | undefined
Expand Down Expand Up @@ -173,7 +175,7 @@ export async function runBrowserActionsCommand({
}) : undefined)
const context = environmentRuntime?.context ?? null
if (context && !session) {
await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin)
await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin, routeTracker)
}
const page = activePage = environmentRuntime?.page ?? await browser.newPage()
if (onProgress) {
Expand Down Expand Up @@ -437,11 +439,12 @@ export async function runBrowserActionsCommand({
pendingError = error instanceof Error ? error : new Error(String(error))
errors.push(serializeBrowserError("probe-error", error))
} finally {
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs).catch((error) => errors.push(serializeBrowserError("probe-error", error)))
if (activePage && resolvedEnvironment) environmentEvidence = await observePlaywrightBrowserEnvironment(activePage, requestedEnvironment, resolvedEnvironment).catch(() => environmentEvidence)
if (!session) {
await environmentRuntime?.close().catch(() => undefined)
await browser.close()
const cleanupBrowser = session ? { close: async () => {} } : { close: async () => { await environmentRuntime?.close(); await browser.close() } }
for (const routeError of await closeBrowserAndDrainPreviewRoutes(cleanupBrowser, routeTracker)) {
errors.push(serializeBrowserError("probe-error", routeError))
pendingError ??= routeError
}
if (capture.has("steps")) {
await artifactSession.writeJsonLines("steps", "steps.jsonl", stepRecords)
Expand Down Expand Up @@ -580,9 +583,7 @@ export async function runBrowserActionsCommand({
throw new Error("wordpress.browser-actions did not produce a browser artifact")
}

return {
artifact,
output: `${JSON.stringify({
return browserCommandResult(artifact, {
command: "wordpress.browser-actions",
requestedUrl,
preview,
Expand All @@ -591,8 +592,7 @@ export async function runBrowserActionsCommand({
files: artifact.files,
summary: artifact.summary,
steps: stepRecords,
}, null, 2)}\n`,
}
})
}

export function browserToolVerifierUnsupportedResult(step: BrowserInteractionStep, index: number, startedAt: string): BrowserToolVerifierResult {
Expand Down Expand Up @@ -967,6 +967,7 @@ export async function runBrowserScenarioCommand({
let pendingError: Error | undefined
let scenarioSession: PlaywrightBrowserEnvironmentSession | undefined
let scenarioBrowser: Awaited<ReturnType<typeof launchChromiumBrowser>> | undefined
let scenarioRouteTracker: ReturnType<typeof createBrowserPreviewRouteTracker> | undefined

try {
if (runPlan.probe && runPlan.actions) {
Expand All @@ -976,12 +977,16 @@ export async function runBrowserScenarioCommand({
if (unsupported.length > 0) {
throw new Error(`wordpress.browser-scenario browser environment is unsupported: ${unsupported.join(", ")}`)
}
const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin)
const runtime = await createPlaywrightBrowserEnvironmentContext(browser, resolved, {
...(runPlan.actions.storageStateImport ? { contextOptions: { storageState: runPlan.actions.storageStateImport.storageState } } : {}),
contextOptions: {
...topology.contextOptions(),
...(runPlan.actions.storageStateImport ? { storageState: runPlan.actions.storageStateImport.storageState } : {}),
},
})
const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl)
if (browserPreviewNeedsContextRouting(topology.networkPolicy)) await routeBrowserPreviewContextNetwork(runtime.context, topology.networkPolicy, topology.preview.effectiveOrigin)
scenarioSession = { browser, requested: requestedEnvironment, resolved, runtime }
const routeTracker = scenarioRouteTracker = createBrowserPreviewRouteTracker()
if (browserPreviewNeedsContextRouting(topology.networkPolicy)) await routeBrowserPreviewContextNetwork(runtime.context, topology.networkPolicy, topology.origins.localProxyOrigin, routeTracker)
scenarioSession = { browser, requested: requestedEnvironment, resolved, routeTracker, runtime }
}

if (runPlan.probe) {
Expand All @@ -1006,8 +1011,15 @@ export async function runBrowserScenarioCommand({
}
}
} finally {
await scenarioSession?.runtime.close().catch(() => undefined)
await scenarioBrowser?.close().catch(() => undefined)
if (scenarioSession && scenarioBrowser && scenarioRouteTracker) {
const activeSession = scenarioSession
const activeBrowser = scenarioBrowser
const cleanupBrowser = { close: async () => { await activeSession.runtime.close(); await activeBrowser.close() } }
const routeErrors = await closeBrowserAndDrainPreviewRoutes(cleanupBrowser, scenarioRouteTracker)
pendingError ??= routeErrors[0]
} else {
await scenarioBrowser?.close().catch(() => undefined)
}
}

const primaryArtifact = actionsResult?.artifact ?? probeResult?.artifact
Expand Down Expand Up @@ -1067,17 +1079,14 @@ export async function runBrowserScenarioCommand({
throw new BrowserCommandArtifactError(`wordpress.browser-scenario failed: ${pendingError.message}`, artifact)
}

return {
artifact,
output: `${JSON.stringify({
return browserCommandResult(artifact, {
command: "wordpress.browser-scenario",
requestedUrl: artifact.requestedUrl,
finalUrl,
files: artifact.files,
summary: artifact.summary,
scenario: scenarioSummary,
}, null, 2)}\n`,
}
})
}

async function browserScenarioFromArgs(args: string[]): Promise<BrowserScenarioInput> {
Expand Down
5 changes: 3 additions & 2 deletions packages/runtime-playground/src/browser-artifact-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { basename, join } from "node:path"
import type { ArtifactProvenanceMetadata } from "@automattic/wp-codebox-core"
import { ArtifactBundleWriter } from "./artifact-bundle-writer.js"
import { browserArtifactFileManifest, type BrowserArtifactFiles } from "./browser-artifacts.js"
import { sanitizeBrowserResultValue } from "./browser-result-sanitization.js"

export class BrowserArtifactSession {
readonly writer: ArtifactBundleWriter
Expand Down Expand Up @@ -32,11 +33,11 @@ export class BrowserArtifactSession {
}

async writeJson(key: keyof BrowserArtifactFiles, fileName: string, value: unknown): Promise<void> {
await this.writer.writeJson(this.path(fileName), value, this.manifestWithoutContentType(key))
await this.writer.writeJson(this.path(fileName), sanitizeBrowserResultValue(value), this.manifestWithoutContentType(key))
}

async writeJsonLines(key: keyof BrowserArtifactFiles, fileName: string, records: unknown[]): Promise<void> {
await this.writer.writeJsonLines(this.path(fileName), records, this.manifestWithoutContentType(key))
await this.writer.writeJsonLines(this.path(fileName), sanitizeBrowserResultValue(records), this.manifestWithoutContentType(key))
}

async writeGenerated(key: keyof BrowserArtifactFiles, fileName: string, write: (absolutePath: string) => Promise<void>): Promise<void> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { BrowserArtifact } from "./browser-artifacts.js"
import { sanitizeBrowserArtifact, sanitizeBrowserResultValue } from "./browser-result-sanitization.js"

export class BrowserCommandArtifactError extends Error {
constructor(message: string, readonly artifact: BrowserArtifact, readonly artifactRoot?: string) {
super(message)
readonly artifact: BrowserArtifact

constructor(message: string, artifact: BrowserArtifact, readonly artifactRoot?: string) {
super(sanitizeBrowserResultValue(message, "message"))
this.name = "BrowserCommandArtifactError"
Object.assign(artifact, sanitizeBrowserArtifact(artifact))
this.artifact = artifact
}
}

Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-playground/src/browser-environment-matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
} from "@automattic/wp-codebox-core"
import type { Browser, BrowserContext, BrowserContextOptions, Page } from "playwright"
import type { BrowserArtifactSummary } from "./browser-artifacts.js"
import type { BrowserPreviewRouteTracker } from "./browser-preview-routing.js"

export const PLAYWRIGHT_BROWSER_ENVIRONMENT_CAPABILITIES = [
"browser.environment.viewport",
Expand Down Expand Up @@ -75,6 +76,7 @@ export interface PlaywrightBrowserEnvironmentSession {
browser: Browser
requested: BrowserEnvironment
resolved: ResolvedBrowserEnvironment
routeTracker?: BrowserPreviewRouteTracker
runtime: PlaywrightBrowserEnvironmentRuntime
}

Expand Down
13 changes: 9 additions & 4 deletions packages/runtime-playground/src/browser-metrics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -527,7 +527,7 @@ export async function serializeBrowserFinishedRequest(request: Request, timestam
if (!response) {
return {
type: "response",
url: request.url(),
url: redactBrowserNetworkUrl(request.url()),
method: request.method(),
resourceType: request.resourceType(),
timestamp,
Expand All @@ -545,7 +545,7 @@ export async function serializeBrowserResponse(response: Response, timestamp = n
const responseTextPreview = await browserDocument5xxResponsePreview(response)
return {
type: "response",
url: response.url(),
url: redactBrowserNetworkUrl(response.url()),
method: request.method(),
resourceType: request.resourceType(),
status: response.status(),
Expand Down Expand Up @@ -596,17 +596,22 @@ function redactBrowserResponseText(body: string): string {
}

export function serializeBrowserRequestFailure(request: Request, timestamp = now()): BrowserProbeNetworkRecord {
const failure = request.failure()
return {
type: "requestfailed",
url: request.url(),
url: redactBrowserNetworkUrl(request.url()),
method: request.method(),
resourceType: request.resourceType(),
timing: browserRequestTiming(request),
failure: request.failure(),
failure: failure ? { errorText: redactString(failure.errorText, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true }) } : null,
timestamp,
}
}

function redactBrowserNetworkUrl(url: string): string {
return redactString(url, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
}

function browserRequestTiming(request: Request): Record<string, number> {
return Object.fromEntries(
Object.entries(request.timing()).filter((entry): entry is [string, number] => typeof entry[1] === "number" && Number.isFinite(entry[1])),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ 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 { browserPreviewReadinessError, browserPreviewTopology, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
import { browserPreviewReadinessError, browserPreviewTopology, closeBrowserAndDrainPreviewRoutes, createBrowserPreviewRouteTracker, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
import { browserCommandResult } from "./browser-result-sanitization.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 @@ -33,6 +34,7 @@ export async function runBrowserMultiActorScenarioCommand(input: {
const browserArgs = routeHost && !scenario.browserArgs.some((arg) => arg.startsWith("route-host=")) ? [...scenario.browserArgs, `route-host=${routeHost}`] : scenario.browserArgs
const topology = browserPreviewTopology(browserArgs, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin)
const browser = await launchChromiumBrowser()
const routeTracker = createBrowserPreviewRouteTracker()
const evidence: Record<string, ActorEvidence> = {}
let result: BrowserMultiActorScenarioResult | undefined
let failure: Error | undefined
Expand All @@ -56,7 +58,7 @@ export async function runBrowserMultiActorScenarioCommand(input: {
const userId = await actorUserId(actor.name, session.user.userId, session.user, runtimeSpec, runPlaygroundCommand, server)
const environmentRuntime = await createPlaywrightBrowserEnvironmentContext(browser, resolvedEnvironment, { contextOptions: topology.contextOptions() })
const context = environmentRuntime.context
await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.origins.localProxyOrigin)
await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.origins.localProxyOrigin, routeTracker)
const page = environmentRuntime.page
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 All @@ -73,7 +75,8 @@ export async function runBrowserMultiActorScenarioCommand(input: {
failure = error instanceof Error ? error : new Error(String(error))
if (error instanceof BrowserMultiActorScenarioError) result = error.result
} finally {
await browser.close()
const routeErrors = await closeBrowserAndDrainPreviewRoutes(browser, routeTracker)
failure ??= routeErrors[0]
}

const replay = result?.replay ?? { schema: "wp-codebox/browser-multi-actor-replay/v1", seed: scenario.seed, scenario, schedule: [] }
Expand All @@ -91,7 +94,7 @@ export async function runBrowserMultiActorScenarioCommand(input: {
const traces = Object.values(evidence).map((actor) => actor.files.trace).filter((path): path is string => Boolean(path))
const artifact = { artifactType: "scenario" as const, requestedUrl: target, url: target, preview: topology.preview, ...topology.origins, files: { summary: "files/browser/multi-actor-scenario-summary.json", steps: "files/browser/multi-actor-events.json", network: "files/browser/multi-actor-network.json", requestCoverage: "files/browser/multi-actor-request-coverage.json", waterfall: "files/browser/multi-actor-waterfall.json", ...(traces.length > 0 ? { traces } : {}) }, summary: { actions: scenario.actions.length, steps: scenario.actions.length, consoleMessages: Object.values(evidence).reduce((total, actor) => total + actor.console.length, 0), errors: Object.values(evidence).reduce((total, actor) => total + actor.errors.length, 0), finalUrl: target, htmlSnapshot: false, networkEvents: network.length, replayability: browserProbeReplayability(captures), screenshot: captures.has("screenshot"), viewport: null, environment: Object.values(evidence)[0]?.environment, multiActor: { seed: scenario.seed, finalState: result?.finalState ?? "failed", actors: Object.keys(evidence), replay: "files/browser/multi-actor-replay.json" } } } satisfies BrowserArtifact
if (failure) throw new BrowserCommandArtifactError(`wordpress.browser-scenario failed: ${failure.message}`, artifact)
return { artifact, output: `${JSON.stringify({ command: "wordpress.browser-scenario", files: artifact.files, summary: artifact.summary, scenario: summary }, null, 2)}\n` }
return browserCommandResult(artifact, { command: "wordpress.browser-scenario", files: artifact.files, summary: artifact.summary, scenario: summary })
}

export async function navigateBrowserMultiActorPages(
Expand Down
Loading
Loading