Skip to content

Commit 5ea1b62

Browse files
committed
fix: secure routed browser command boundaries
1 parent 2b76447 commit 5ea1b62

14 files changed

Lines changed: 285 additions & 79 deletions

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ on:
1414
- "fixtures/agent-task-runtime-paths-run-29305012941.json"
1515
- "package-lock.json"
1616
- "package.json"
17+
- "packages/runtime-playground/src/**"
18+
- "tests/browser-*.test.ts"
19+
- "tests/editor-*.test.ts"
1720
- "tests/agent-task-*.test.ts"
1821
- "tests/runtime-sources-materialization.test.ts"
1922
- "tests/runtime-sources-playground-integration.test.ts"
@@ -55,6 +58,9 @@ on:
5558
- "fixtures/agent-task-runtime-paths-run-29305012941.json"
5659
- "package-lock.json"
5760
- "package.json"
61+
- "packages/runtime-playground/src/**"
62+
- "tests/browser-*.test.ts"
63+
- "tests/editor-*.test.ts"
5864
- "tests/agent-task-*.test.ts"
5965
- "tests/runtime-sources-materialization.test.ts"
6066
- "tests/runtime-sources-playground-integration.test.ts"
@@ -123,6 +129,7 @@ jobs:
123129
- run: npm run test:runtime-command-artifact-bounds
124130
- run: npm run test:redaction
125131
- run: npm run test:browser-preview-routing
132+
- run: npm run test:browser-routed-command-security
126133
- run: npm run test:production-boundary-enforcement
127134
- run: npm run test:runtime-tool-policy
128135

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@
109109
"smoke": "tsx scripts/run-smoke.ts",
110110
"test:redaction": "tsx tests/redaction.test.ts",
111111
"test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts",
112+
"test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts",
112113
"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",
113114
"test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts",
114115
"test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts",

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

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ 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, browserPreviewReadinessError, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
15+
import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewTopology, closeBrowserAndDrainPreviewRoutes, createBrowserPreviewRouteTracker, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
16+
import { browserCommandResult } from "./browser-result-sanitization.js"
1617
import { BROWSER_PROBE_STATE_INIT_SCRIPT, browserProbeReplayability, browserProbeViewport } from "./browser-probe.js"
1718
import { runBrowserProbeCommand, type BrowserProbeRunPlan } from "./browser-probe-runner.js"
1819
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"
@@ -127,6 +128,7 @@ export async function runBrowserActionsCommand({
127128
const browser = await launchChromiumBrowser()
128129
const topology = browserPreviewTopology(args, runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin)
129130
const { preview, networkPolicy } = topology
131+
const routeTracker = createBrowserPreviewRouteTracker()
130132
let requestedUrl = initialUrl ? topology.resolveUrl(initialUrl) : preview.effectiveOrigin
131133
let finalUrl = requestedUrl
132134
let htmlSha256: string | undefined
@@ -153,7 +155,7 @@ export async function runBrowserActionsCommand({
153155
...(storageStateImport ? { storageState: storageStateImport.storageState } : {}),
154156
}) : null
155157
if (context) {
156-
await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin)
158+
await routeBrowserPreviewContextNetwork(context, networkPolicy, topology.origins.localProxyOrigin, routeTracker)
157159
}
158160
const page = context ? await context.newPage() : await browser.newPage()
159161
if (onProgress) {
@@ -418,8 +420,11 @@ export async function runBrowserActionsCommand({
418420
pendingError = error instanceof Error ? error : new Error(String(error))
419421
errors.push(serializeBrowserError("probe-error", error))
420422
} finally {
421-
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
422-
await browser.close()
423+
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs).catch((error) => errors.push(serializeBrowserError("probe-error", error)))
424+
for (const routeError of await closeBrowserAndDrainPreviewRoutes(browser, routeTracker)) {
425+
errors.push(serializeBrowserError("probe-error", routeError))
426+
pendingError ??= routeError
427+
}
423428
if (capture.has("steps")) {
424429
await artifactSession.writeJsonLines("steps", "steps.jsonl", stepRecords)
425430
}
@@ -555,9 +560,7 @@ export async function runBrowserActionsCommand({
555560
throw new Error("wordpress.browser-actions did not produce a browser artifact")
556561
}
557562

558-
return {
559-
artifact,
560-
output: `${JSON.stringify({
563+
return browserCommandResult(artifact, {
561564
command: "wordpress.browser-actions",
562565
requestedUrl,
563566
preview,
@@ -566,8 +569,7 @@ export async function runBrowserActionsCommand({
566569
files: artifact.files,
567570
summary: artifact.summary,
568571
steps: stepRecords,
569-
}, null, 2)}\n`,
570-
}
572+
})
571573
}
572574

573575
export function browserToolVerifierUnsupportedResult(step: BrowserInteractionStep, index: number, startedAt: string): BrowserToolVerifierResult {
@@ -952,17 +954,14 @@ export async function runBrowserScenarioCommand({
952954
throw new BrowserCommandArtifactError(`wordpress.browser-scenario failed: ${pendingError.message}`, artifact)
953955
}
954956

955-
return {
956-
artifact,
957-
output: `${JSON.stringify({
957+
return browserCommandResult(artifact, {
958958
command: "wordpress.browser-scenario",
959959
requestedUrl: artifact.requestedUrl,
960960
finalUrl,
961961
files: artifact.files,
962962
summary: artifact.summary,
963963
scenario: scenarioSummary,
964-
}, null, 2)}\n`,
965-
}
964+
})
966965
}
967966

968967
async function browserScenarioFromArgs(args: string[]): Promise<BrowserScenarioInput> {

packages/runtime-playground/src/browser-artifact-session.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { basename, join } from "node:path"
33
import type { ArtifactProvenanceMetadata } from "@automattic/wp-codebox-core"
44
import { ArtifactBundleWriter } from "./artifact-bundle-writer.js"
55
import { browserArtifactFileManifest, type BrowserArtifactFiles } from "./browser-artifacts.js"
6+
import { sanitizeBrowserResultValue } from "./browser-result-sanitization.js"
67

78
export class BrowserArtifactSession {
89
readonly writer: ArtifactBundleWriter
@@ -32,11 +33,11 @@ export class BrowserArtifactSession {
3233
}
3334

3435
async writeJson(key: keyof BrowserArtifactFiles, fileName: string, value: unknown): Promise<void> {
35-
await this.writer.writeJson(this.path(fileName), value, this.manifestWithoutContentType(key))
36+
await this.writer.writeJson(this.path(fileName), sanitizeBrowserResultValue(value), this.manifestWithoutContentType(key))
3637
}
3738

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

4243
async writeGenerated(key: keyof BrowserArtifactFiles, fileName: string, write: (absolutePath: string) => Promise<void>): Promise<void> {

packages/runtime-playground/src/browser-command-artifact-error.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
11
import type { BrowserArtifact } from "./browser-artifacts.js"
2+
import { sanitizeBrowserArtifact, sanitizeBrowserResultValue } from "./browser-result-sanitization.js"
23

34
export class BrowserCommandArtifactError extends Error {
4-
constructor(message: string, readonly artifact: BrowserArtifact, readonly artifactRoot?: string) {
5-
super(message)
5+
readonly artifact: BrowserArtifact
6+
7+
constructor(message: string, artifact: BrowserArtifact, readonly artifactRoot?: string) {
8+
super(sanitizeBrowserResultValue(message, "message"))
69
this.name = "BrowserCommandArtifactError"
10+
Object.assign(artifact, sanitizeBrowserArtifact(artifact))
11+
this.artifact = artifact
712
}
813
}
914

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ 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 { browserPreviewReadinessError, browserPreviewTopology, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
9+
import { browserPreviewReadinessError, browserPreviewTopology, closeBrowserAndDrainPreviewRoutes, createBrowserPreviewRouteTracker, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
10+
import { browserCommandResult } from "./browser-result-sanitization.js"
1011
import { installWordPressAdminAuthCookies } from "./browser-probe-support.js"
1112
import { bootstrapPhpCode } from "./php-bootstrap.js"
1213
import { assertPlaygroundResponseOk, type PlaygroundRunResponse } from "./playground-command-errors.js"
@@ -31,6 +32,7 @@ export async function runBrowserMultiActorScenarioCommand(input: {
3132
const routeHost = runtimeSpec.preview?.siteUrl ? new URL(runtimeSpec.preview.siteUrl).hostname : ""
3233
const topology = browserPreviewTopology(routeHost ? [`route-host=${routeHost}`] : [], runtimeSpec, server.serverUrl, server.previewProxyDiagnostics?.targetOrigin)
3334
const browser = await launchChromiumBrowser()
35+
const routeTracker = createBrowserPreviewRouteTracker()
3436
const evidence: Record<string, ActorEvidence> = {}
3537
let result: BrowserMultiActorScenarioResult | undefined
3638
let failure: Error | undefined
@@ -49,7 +51,7 @@ export async function runBrowserMultiActorScenarioCommand(input: {
4951
if (!session) throw new Error(`Actor ${actor.name} requires user session ${actor.userSession}`)
5052
const userId = await actorUserId(actor.name, session.user.userId, session.user, runtimeSpec, runPlaygroundCommand, server)
5153
const context = await browser.newContext(topology.contextOptions())
52-
await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.origins.localProxyOrigin)
54+
await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.origins.localProxyOrigin, routeTracker)
5355
const page = await context.newPage()
5456
await context.tracing.start({ screenshots: true, snapshots: true })
5557
await installWordPressAdminAuthCookies({ command: "wordpress.browser-scenario", cookieUrls: topology.authCookieUrls([topology.resolveUrl(scenario.url)]), page, runPlaygroundCommand, runtimeSpec, server, userId })
@@ -66,7 +68,8 @@ export async function runBrowserMultiActorScenarioCommand(input: {
6668
failure = error instanceof Error ? error : new Error(String(error))
6769
if (error instanceof BrowserMultiActorScenarioError) result = error.result
6870
} finally {
69-
await browser.close()
71+
const routeErrors = await closeBrowserAndDrainPreviewRoutes(browser, routeTracker)
72+
failure ??= routeErrors[0]
7073
}
7174

7275
const replay = result?.replay ?? { schema: "wp-codebox/browser-multi-actor-replay/v1", seed: scenario.seed, scenario, schedule: [] }
@@ -84,7 +87,7 @@ export async function runBrowserMultiActorScenarioCommand(input: {
8487
const traces = Object.values(evidence).map((actor) => actor.files.trace).filter((path): path is string => Boolean(path))
8588
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, multiActor: { seed: scenario.seed, finalState: result?.finalState ?? "failed", actors: Object.keys(evidence), replay: "files/browser/multi-actor-replay.json" } } } satisfies BrowserArtifact
8689
if (failure) throw new BrowserCommandArtifactError(`wordpress.browser-scenario failed: ${failure.message}`, artifact)
87-
return { artifact, output: `${JSON.stringify({ command: "wordpress.browser-scenario", files: artifact.files, summary: artifact.summary, scenario: summary }, null, 2)}\n` }
90+
return browserCommandResult(artifact, { command: "wordpress.browser-scenario", files: artifact.files, summary: artifact.summary, scenario: summary })
8891
}
8992

9093
export async function navigateBrowserMultiActorPages(

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

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,22 @@ export async function drainBrowserPreviewRouteTracker(tracker: BrowserPreviewRou
350350
}
351351
}
352352

353+
export async function closeBrowserAndDrainPreviewRoutes(browser: Pick<import("playwright").Browser, "close">, tracker: BrowserPreviewRouteTracker): Promise<Error[]> {
354+
const errors: Error[] = []
355+
try {
356+
await browser.close()
357+
} catch (error) {
358+
errors.push(browserPreviewLifecycleError("browser-close", error))
359+
} finally {
360+
try {
361+
await drainBrowserPreviewRouteTracker(tracker)
362+
} catch (error) {
363+
errors.push(browserPreviewLifecycleError("route-drain", error))
364+
}
365+
}
366+
return errors
367+
}
368+
353369
function browserPreviewMode(args: string[], publicOrigin: string | undefined): BrowserProbePreviewMode {
354370
const raw = argValue(args, "preview-mode")?.trim() || (publicOrigin ? "public" : "local")
355371
if (raw === "local" || raw === "public" || raw === "secure") {
@@ -641,6 +657,13 @@ function browserPreviewRouteRequestSummary(route: Route): { method: string; reso
641657
}
642658
}
643659

660+
function browserPreviewLifecycleError(operation: string, error: unknown): Error {
661+
const cause = sanitizeBrowserPreviewRouteError(error).message.replace(/[\r\n]+/g, " ")
662+
const diagnostic = new Error(`wordpress.browser-probe route lifecycle failed: operation=${operation} cause=${cause}`)
663+
diagnostic.name = "BrowserPreviewRouteLifecycleError"
664+
return diagnostic
665+
}
666+
644667
function browserPreviewRouteFetchExhaustedError(route: Route, requestUrl: URL, attempts: number, error: unknown): Error {
645668
const method = route.request().method()
646669
const resourceType = route.request().resourceType()

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

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { attachBrowserCaptureListeners, chromiumBrowserMetadata, launchChromiumB
66
import { browserCommandLivenessPolicy, isBrowserCommandLivenessError, withBrowserCommandLiveness } from "./browser-liveness.js"
77
import { browserProbeLifecycleArtifact, browserProbeLifecycleInitScript, collectBrowserProbeLifecycle } from "./browser-lifecycle.js"
88
import { browserProbeBenchMetrics, serializeBrowserError } from "./browser-metrics.js"
9-
import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewSecureContextError, browserPreviewTopology, createBrowserPreviewRouteTracker, drainBrowserPreviewRouteTracker, routeBrowserPreviewContextNetwork, routeBrowserPreviewPageNetwork } from "./browser-preview-routing.js"
9+
import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, browserPreviewReadinessError, browserPreviewSecureContextError, browserPreviewTopology, closeBrowserAndDrainPreviewRoutes, createBrowserPreviewRouteTracker, drainBrowserPreviewRouteTracker, routeBrowserPreviewContextNetwork, routeBrowserPreviewPageNetwork } from "./browser-preview-routing.js"
1010
import { BROWSER_PROBE_PERFORMANCE_INIT_SCRIPT, BROWSER_PROBE_STATE_INIT_SCRIPT, browserProbeAssertionsFromArgs, browserProbeCheckpoint, browserProbeMemoryArtifact, browserProbePendingCheckpoints, browserProbePerformanceArtifact, browserProbeViewport, executeBrowserProbeAssertions, navigateBrowserProbe } from "./browser-probe.js"
1111
import { argValue, commaListArg, durationArg, strictBooleanArg, viewportArg } from "./commands.js"
1212
import type { PlaygroundRunResponse } from "./playground-command-errors.js"
@@ -493,18 +493,28 @@ export async function runSingleBrowserProbeCommand({
493493
}
494494
}
495495
}
496-
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
497-
await geolocationPermissionCleanup?.()
498-
await browser.close()
499496
try {
500-
await drainBrowserPreviewRouteTracker(routeTracker)
497+
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
498+
} catch (error) {
499+
errors.push(serializeBrowserError("probe-error", error))
500+
}
501+
try {
502+
await geolocationPermissionCleanup?.()
501503
} catch (error) {
502-
const routeError = redactError(error, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
503-
if (!pendingError && runPlan.routeHostDrain === "required") {
504+
const cleanupError = redactError(error, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
505+
if (!pendingError) {
506+
pendingError = cleanupError
507+
progress.fail("probe-error", cleanupError)
508+
}
509+
errors.push(serializeBrowserError("probe-error", error))
510+
}
511+
for (const routeError of await closeBrowserAndDrainPreviewRoutes(browser, routeTracker)) {
512+
const browserCloseFailed = routeError.message.includes("operation=browser-close")
513+
if (!pendingError && (browserCloseFailed || runPlan.routeHostDrain === "required")) {
504514
pendingError = routeError
505515
progress.fail("probe-error", routeError)
506516
}
507-
errors.push(serializeBrowserError("probe-error", error))
517+
errors.push(serializeBrowserError("probe-error", routeError))
508518
}
509519
if (captureSelection.console) {
510520
await artifactSession.writeJsonLines("console", "console.jsonl", consoleMessages)

0 commit comments

Comments
 (0)