Skip to content

Commit 1eefc9a

Browse files
committed
fix: secure routed browser command boundaries
1 parent 40ab352 commit 1eefc9a

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"
@@ -47,6 +50,9 @@ on:
4750
- "fixtures/agent-task-runtime-paths-run-29305012941.json"
4851
- "package-lock.json"
4952
- "package.json"
53+
- "packages/runtime-playground/src/**"
54+
- "tests/browser-*.test.ts"
55+
- "tests/editor-*.test.ts"
5056
- "tests/agent-task-*.test.ts"
5157
- "tests/runtime-sources-materialization.test.ts"
5258
- "tests/runtime-sources-playground-integration.test.ts"
@@ -99,6 +105,7 @@ jobs:
99105
- run: npm run test:runtime-command-artifact-bounds
100106
- run: npm run test:redaction
101107
- run: npm run test:browser-preview-routing
108+
- run: npm run test:browser-routed-command-security
102109
- run: npm run test:production-boundary-enforcement
103110
- run: npm run test:runtime-tool-policy
104111

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, browserPreviewTopology, resolveBrowserPreviewUrl, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
15+
import { browserPreviewNetworkPolicyIsActive, browserPreviewNetworkPolicySummary, browserPreviewNeedsContextRouting, 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)
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
@@ -148,7 +150,7 @@ export async function runBrowserActionsCommand({
148150
...(storageStateImport ? { storageState: storageStateImport.storageState } : {}),
149151
}) : null
150152
if (context) {
151-
await routeBrowserPreviewContextNetwork(context, networkPolicy, preview.effectiveOrigin)
153+
await routeBrowserPreviewContextNetwork(context, networkPolicy, preview.effectiveOrigin, routeTracker)
152154
}
153155
const page = context ? await context.newPage() : await browser.newPage()
154156
if (onProgress) {
@@ -410,8 +412,11 @@ export async function runBrowserActionsCommand({
410412
}
411413
}
412414
} finally {
413-
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
414-
await browser.close()
415+
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs).catch((error) => errors.push(serializeBrowserError("probe-error", error)))
416+
for (const routeError of await closeBrowserAndDrainPreviewRoutes(browser, routeTracker)) {
417+
errors.push(serializeBrowserError("probe-error", routeError))
418+
pendingError ??= routeError
419+
}
415420
if (capture.has("steps")) {
416421
await artifactSession.writeJsonLines("steps", "steps.jsonl", stepRecords)
417422
}
@@ -547,9 +552,7 @@ export async function runBrowserActionsCommand({
547552
throw new Error("wordpress.browser-actions did not produce a browser artifact")
548553
}
549554

550-
return {
551-
artifact,
552-
output: `${JSON.stringify({
555+
return browserCommandResult(artifact, {
553556
command: "wordpress.browser-actions",
554557
requestedUrl,
555558
preview,
@@ -558,8 +561,7 @@ export async function runBrowserActionsCommand({
558561
files: artifact.files,
559562
summary: artifact.summary,
560563
steps: stepRecords,
561-
}, null, 2)}\n`,
562-
}
564+
})
563565
}
564566

565567
export function browserToolVerifierUnsupportedResult(step: BrowserInteractionStep, index: number, startedAt: string): BrowserToolVerifierResult {
@@ -944,17 +946,14 @@ export async function runBrowserScenarioCommand({
944946
throw new BrowserCommandArtifactError(`wordpress.browser-scenario failed: ${pendingError.message}`, artifact)
945947
}
946948

947-
return {
948-
artifact,
949-
output: `${JSON.stringify({
949+
return browserCommandResult(artifact, {
950950
command: "wordpress.browser-scenario",
951951
requestedUrl: artifact.requestedUrl,
952952
finalUrl,
953953
files: artifact.files,
954954
summary: artifact.summary,
955955
scenario: scenarioSummary,
956-
}, null, 2)}\n`,
957-
}
956+
})
958957
}
959958

960959
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 { browserPreviewTopology, routeBrowserPreviewContextNetwork } from "./browser-preview-routing.js"
9+
import { 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"
@@ -30,6 +31,7 @@ export async function runBrowserMultiActorScenarioCommand(input: {
3031
const artifacts = new BrowserArtifactSession(artifactRoot, "files/browser", { source: "wordpress.browser-scenario", operation: "browser-multi-actor-scenario" })
3132
const topology = browserPreviewTopology([], runtimeSpec, server.serverUrl)
3233
const browser = await launchChromiumBrowser()
34+
const routeTracker = createBrowserPreviewRouteTracker()
3335
const evidence: Record<string, ActorEvidence> = {}
3436
let result: BrowserMultiActorScenarioResult | undefined
3537
let failure: Error | undefined
@@ -44,7 +46,7 @@ export async function runBrowserMultiActorScenarioCommand(input: {
4446
if (!session) throw new Error(`Actor ${actor.name} requires user session ${actor.userSession}`)
4547
const userId = await actorUserId(actor.name, session.user.userId, session.user, runtimeSpec, runPlaygroundCommand, server)
4648
const context = await browser.newContext()
47-
await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.preview.effectiveOrigin)
49+
await routeBrowserPreviewContextNetwork(context, topology.networkPolicy, topology.preview.effectiveOrigin, routeTracker)
4850
const page = await context.newPage()
4951
await context.tracing.start({ screenshots: true, snapshots: true })
5052
await installWordPressAdminAuthCookies({ command: "wordpress.browser-scenario", cookieUrls: topology.authCookieUrls([topology.resolveUrl(scenario.url)]), page, runPlaygroundCommand, runtimeSpec, server, userId })
@@ -61,7 +63,8 @@ export async function runBrowserMultiActorScenarioCommand(input: {
6163
failure = error instanceof Error ? error : new Error(String(error))
6264
if (error instanceof BrowserMultiActorScenarioError) result = error.result
6365
} finally {
64-
await browser.close()
66+
const routeErrors = await closeBrowserAndDrainPreviewRoutes(browser, routeTracker)
67+
failure ??= routeErrors[0]
6568
}
6669

6770
const replay = result?.replay ?? { schema: "wp-codebox/browser-multi-actor-replay/v1", seed: scenario.seed, scenario, schedule: [] }
@@ -79,7 +82,7 @@ export async function runBrowserMultiActorScenarioCommand(input: {
7982
const traces = Object.values(evidence).map((actor) => actor.files.trace).filter((path): path is string => Boolean(path))
8083
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
8184
if (failure) throw new BrowserCommandArtifactError(`wordpress.browser-scenario failed: ${failure.message}`, artifact)
82-
return { artifact, output: `${JSON.stringify({ command: "wordpress.browser-scenario", files: artifact.files, summary: artifact.summary, scenario: summary }, null, 2)}\n` }
85+
return browserCommandResult(artifact, { command: "wordpress.browser-scenario", files: artifact.files, summary: artifact.summary, scenario: summary })
8386
}
8487

8588
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
@@ -296,6 +296,22 @@ export async function drainBrowserPreviewRouteTracker(tracker: BrowserPreviewRou
296296
}
297297
}
298298

299+
export async function closeBrowserAndDrainPreviewRoutes(browser: Pick<import("playwright").Browser, "close">, tracker: BrowserPreviewRouteTracker): Promise<Error[]> {
300+
const errors: Error[] = []
301+
try {
302+
await browser.close()
303+
} catch (error) {
304+
errors.push(browserPreviewLifecycleError("browser-close", error))
305+
} finally {
306+
try {
307+
await drainBrowserPreviewRouteTracker(tracker)
308+
} catch (error) {
309+
errors.push(browserPreviewLifecycleError("route-drain", error))
310+
}
311+
}
312+
return errors
313+
}
314+
299315
function browserPreviewMode(args: string[], publicOrigin: string | undefined): BrowserProbePreviewMode {
300316
const raw = argValue(args, "preview-mode")?.trim() || (publicOrigin ? "public" : "local")
301317
if (raw === "local" || raw === "public" || raw === "secure") {
@@ -575,6 +591,13 @@ function browserPreviewRouteRequestSummary(route: Route): { method: string; reso
575591
}
576592
}
577593

594+
function browserPreviewLifecycleError(operation: string, error: unknown): Error {
595+
const cause = sanitizeBrowserPreviewRouteError(error).message.replace(/[\r\n]+/g, " ")
596+
const diagnostic = new Error(`wordpress.browser-probe route lifecycle failed: operation=${operation} cause=${cause}`)
597+
diagnostic.name = "BrowserPreviewRouteLifecycleError"
598+
return diagnostic
599+
}
600+
578601
function browserPreviewRouteFetchExhaustedError(route: Route, requestUrl: URL, attempts: number, error: unknown): Error {
579602
const method = route.request().method()
580603
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"
@@ -492,18 +492,28 @@ export async function runSingleBrowserProbeCommand({
492492
}
493493
}
494494
}
495-
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
496-
await geolocationPermissionCleanup?.()
497-
await browser.close()
498495
try {
499-
await drainBrowserPreviewRouteTracker(routeTracker)
496+
await settleBrowserNetworkTasks(networkTasks, livenessPolicy.networkSettleTimeoutMs)
497+
} catch (error) {
498+
errors.push(serializeBrowserError("probe-error", error))
499+
}
500+
try {
501+
await geolocationPermissionCleanup?.()
500502
} catch (error) {
501-
const routeError = redactError(error, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
502-
if (!pendingError && runPlan.routeHostDrain === "required") {
503+
const cleanupError = redactError(error, { redactAllUrlQueryValues: true, redactUrlHash: true, redactQueryAssignments: true })
504+
if (!pendingError) {
505+
pendingError = cleanupError
506+
progress.fail("probe-error", cleanupError)
507+
}
508+
errors.push(serializeBrowserError("probe-error", error))
509+
}
510+
for (const routeError of await closeBrowserAndDrainPreviewRoutes(browser, routeTracker)) {
511+
const browserCloseFailed = routeError.message.includes("operation=browser-close")
512+
if (!pendingError && (browserCloseFailed || runPlan.routeHostDrain === "required")) {
503513
pendingError = routeError
504514
progress.fail("probe-error", routeError)
505515
}
506-
errors.push(serializeBrowserError("probe-error", error))
516+
errors.push(serializeBrowserError("probe-error", routeError))
507517
}
508518
if (captureSelection.console) {
509519
await artifactSession.writeJsonLines("console", "console.jsonl", consoleMessages)

0 commit comments

Comments
 (0)