From 09c2a2d241fe8f2967fedf81366f7c9d98c46e85 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 08:48:18 +0800 Subject: [PATCH] fix(browser): preserve main-frame identity and scope refusal context Read screenshot identity from the selected frame-tree root instead of a filtered inventory that could substitute a child. Reuse the frame record parser for initial and event identities, rejecting malformed roots before capture while preserving known origin refusals. Share explicit origin-scope diagnostics across CDP workflows without changing policy or conflating startup uncertainty, document replacement, worker protection, and Inspector restrictions. Document the recovery and make screenshot navigation fixture ordering explicit. Validation: 577 focused checks passed across 13 files; one platform-specific invalid-byte-filename case skipped. Build, typecheck, lint, formatting, generated documentation checks, README conformance, and discovery passed. No full suite or live browser/provider execution. --- docs/browser-observation.md | 14 ++- src/browser/CdpAuthorizedMainFrame.ts | 3 +- src/browser/CdpCaptureEventHelpers.ts | 41 +++++---- src/browser/CdpOriginScopeError.ts | 16 ++++ src/browser/CdpPageCapture.ts | 6 +- src/browser/CdpScreenshot.ts | 46 +++++----- src/browser/CdpWebMcpDiscovery.ts | 3 +- src/browser/execution/CdpRuntimeSession.ts | 4 +- .../cdpAuthorizedMainFrameRetry.test.ts | 18 +++- .../browser/cdpCaptureInvalidation.test.ts | 51 +++++++++++ .../cdpScreenshotDocumentIdentity.test.ts | 90 +++++++++++++++++++ .../browser/cdpWebMcpCoverage.test.ts | 45 ++++++++++ tests/boundary/browser/cdpWebRuntime.test.ts | 37 ++++++++ 13 files changed, 326 insertions(+), 48 deletions(-) create mode 100644 src/browser/CdpOriginScopeError.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 9dcc1d82e0..923a2cf786 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -371,9 +371,17 @@ REA establishes a main-frame origin boundary before enabling Runtime, Debugger, An observed navigation also takes precedence over failures from stale document reads, such as a script ID invalidated by reload; cancellation remains cancellation. Retry after navigation settles, or refresh target discovery if -the selected page was replaced. Screenshot origin refusals identify the -current document's permitted-origin scope and direct the caller to select a -permitted document or explicitly revise `allowed_origins`. +the selected page was replaced. Origin-scope refusals from page inspection, +runtime observation, screenshots, and WebMCP discovery explain the permitted +HTTP(S) origin scope and direct the caller to select a permitted document or +explicitly revise `allowed_origins`. Page capture also preserves an observed +main-frame scope violation when a later document read fails. An unresolved +initial main frame does not establish an origin violation; its bounded startup +retries retain a distinct refusal. + +Screenshot capture requires a valid main-frame identity. Malformed identity +produces a protocol failure; a readable child frame cannot substitute for the +main frame. The web-session timeline uses CDP monotonic seconds where the event producer provides a comparable timestamp; absent or invalid timestamps are `null` rather diff --git a/src/browser/CdpAuthorizedMainFrame.ts b/src/browser/CdpAuthorizedMainFrame.ts index 3569030c83..2a7c39c694 100644 --- a/src/browser/CdpAuthorizedMainFrame.ts +++ b/src/browser/CdpAuthorizedMainFrame.ts @@ -1,5 +1,6 @@ import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; +import { cdpOriginScopeRefusal } from "./CdpOriginScopeError.js"; import type { CdpConnection } from "./CdpConnection.js"; import { classifyCdpFrameUrl, @@ -33,7 +34,7 @@ export const authorizedMainFrame = async ({ const observed = observeMainFrameUrl(result); const facts = classifyCdpFrameUrl(observed, allowedOrigins); if (facts.scope === "outside_policy") - throw new BrowserObservationError(operation, "target_not_allowed"); + throw cdpOriginScopeRefusal(operation); if (facts.scope === "approved" && observed.kind === "invalid_fragment") throw new BrowserObservationError(operation, "protocol_error"); if (facts.url !== undefined) return result; diff --git a/src/browser/CdpCaptureEventHelpers.ts b/src/browser/CdpCaptureEventHelpers.ts index 091d4ddec2..ca131b4481 100644 --- a/src/browser/CdpCaptureEventHelpers.ts +++ b/src/browser/CdpCaptureEventHelpers.ts @@ -76,6 +76,18 @@ const parentFacts = ( : { parentRelation: "child", parentId, parentIssue: null }; }; +/** Parse a frame record's identity without inferring its selected-target role. */ +export const cdpFrameRecordIdentity = ( + frame: UnknownRecord | undefined, +): Pick< + CdpFrameIdentity, + "frameId" | "identityIssue" | "parentRelation" | "parentId" | "parentIssue" +> => ({ + frameId: nonEmptyString(frame?.id) ?? null, + identityIssue: identityIssueFor(true, frame, frame?.id), + ...parentFacts(true, frame), +}); + /** Parse required frame identity separately from the optional parent fact. */ export const cdpFrameIdentity = ( event: CdpEvent, @@ -108,32 +120,31 @@ export const cdpFrameIdentity = ( parentId: null, parentIssue: null, }; - const frame = navigated ? recordValue(params.frame) : undefined; - const rawId = navigated ? frame?.id : params.frameId; - const frameId = nonEmptyString(rawId); + const identity = navigated + ? cdpFrameRecordIdentity(recordValue(params.frame)) + : { + frameId: nonEmptyString(params.frameId) ?? null, + identityIssue: identityIssueFor(false, undefined, params.frameId), + ...parentFacts(false, undefined), + }; const selection = - frameId === undefined + identity.frameId === null ? "unknown" - : frameId === mainFrameId + : identity.frameId === mainFrameId ? "main" : "other"; - const identityIssue = identityIssueFor(navigated, frame, rawId); - const parent = parentFacts(navigated, frame); return { applicability: "applicable", selection, isRootReplacement: - selection === "other" && parent.parentRelation === "root", - frameId: frameId ?? null, - identityIssue, - parentRelation: parent.parentRelation, - parentId: parent.parentId, + selection === "other" && identity.parentRelation === "root", + ...identity, parentIssue: selection === "main" && - (parent.parentRelation === "child" || - parent.parentIssue === "self_parent") + (identity.parentRelation === "child" || + identity.parentIssue === "self_parent") ? "main_frame_has_parent" - : parent.parentIssue, + : identity.parentIssue, }; }; diff --git a/src/browser/CdpOriginScopeError.ts b/src/browser/CdpOriginScopeError.ts new file mode 100644 index 0000000000..ab45c75078 --- /dev/null +++ b/src/browser/CdpOriginScopeError.ts @@ -0,0 +1,16 @@ +import { BrowserObservationError } from "../domain/browserObservationError.js"; +import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js"; + +/** Use only after a CDP document is known to be outside its HTTP(S) scope. */ +export const cdpOriginScopeRefusal = ( + operation: BrowserObservationOperation, + options?: { readonly cause?: unknown; readonly detail?: string }, +): BrowserObservationError => + new BrowserObservationError(operation, "target_not_allowed", { + ...options, + detail: + options?.detail ?? + "The current document is outside the permitted HTTP(S) origin scope.", + remediationAction: + "Select a document within allowed_origins, or explicitly adjust allowed_origins if that document is intended, then retry.", + }); diff --git a/src/browser/CdpPageCapture.ts b/src/browser/CdpPageCapture.ts index 936c10fa22..e2550ba8ab 100644 --- a/src/browser/CdpPageCapture.ts +++ b/src/browser/CdpPageCapture.ts @@ -10,6 +10,7 @@ import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js"; import { CdpConnection } from "./CdpConnection.js"; import { CdpCaptureEvents } from "./CdpCaptureEvents.js"; import { authorizedMainFrame } from "./CdpAuthorizedMainFrame.js"; +import { cdpOriginScopeRefusal } from "./CdpOriginScopeError.js"; import { captureStorage } from "./CdpCaptureStorage.js"; import { optionalCdpCommand } from "./CdpOptionalCommand.js"; import { CdpCommandRejection } from "./CdpCommandRejection.js"; @@ -130,6 +131,8 @@ const assertObservedCaptureValid = ( : state.events.navigationDuringCapture ? "target_changed" : undefined; + if (reason === "target_not_allowed") + throw cdpOriginScopeRefusal(state.context.operation, { cause }); if (reason !== undefined) { if (cause instanceof BrowserObservationError && cause.reason === reason) throw cause; @@ -304,8 +307,7 @@ const authorizeObservationWindow = async ( ); if (events.unknownRootIdentity) throw new BrowserObservationError("inspect_web_page", "protocol_error"); - if (events.originViolation) - throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); + if (events.originViolation) throw cdpOriginScopeRefusal(context.operation); }; const capturePageDom = async (state: CaptureState): Promise => { diff --git a/src/browser/CdpScreenshot.ts b/src/browser/CdpScreenshot.ts index 7f67cd0ae0..bf3612d82f 100644 --- a/src/browser/CdpScreenshot.ts +++ b/src/browser/CdpScreenshot.ts @@ -5,7 +5,11 @@ import type { } from "../domain/webScreenshot.js"; import { createWebScreenshotArtifact } from "../domain/webScreenshot.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; -import { cdpFrameIdentity } from "./CdpCaptureEventHelpers.js"; +import { cdpOriginScopeRefusal } from "./CdpOriginScopeError.js"; +import { + cdpFrameIdentity, + cdpFrameRecordIdentity, +} from "./CdpCaptureEventHelpers.js"; import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js"; import type { CdpConnection, CdpEvent } from "./CdpConnection.js"; import { @@ -17,7 +21,6 @@ import { observeCdpFrameUrl, observeMainFrameUrl, classifyCdpFrameUrl, - captureFrames, } from "./CdpCaptureDocuments.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; @@ -34,14 +37,6 @@ interface ScreenshotContext { readonly progress?: ProgressReporter; } -const screenshotOriginRefusal = (): BrowserObservationError => - new BrowserObservationError("inspect_web_page", "target_not_allowed", { - detail: - "The current screenshot document is outside the permitted HTTP(S) origin scope.", - remediationAction: - "Select a document within allowed_origins, or explicitly adjust allowed_origins if that document is intended, then retry.", - }); - /** Capture the current viewport without navigation, evaluation, or page mutation. */ export const captureCdpScreenshot = async ( context: ScreenshotContext, @@ -65,31 +60,36 @@ export const captureCdpScreenshot = async ( context.sessionId, context.signal, ); - const beforeObservation = observeMainFrameUrl(before); + const initialFrame = recordValue( + recordValue(recordValue(before)?.frameTree)?.frame, + ); + const beforeObservation = observeCdpFrameUrl(initialFrame); const beforeFacts = classifyCdpFrameUrl(beforeObservation, origins); if ( beforeFacts.scope === "outside_policy" || beforeFacts.scope === "unsupported" ) - throw screenshotOriginRefusal(); + throw cdpOriginScopeRefusal("capture_web_screenshot"); if (beforeObservation.kind !== "observed") throw new BrowserObservationError("inspect_web_page", "protocol_error"); const beforeUrl = beforeFacts.url; - const mainFrame = captureFrames(before, origins, 1).items[0]; + const mainFrame = cdpFrameRecordIdentity(initialFrame); if ( beforeUrl === undefined || beforeFacts.origin === null || - mainFrame === undefined + mainFrame.frameId === null || + mainFrame.identityIssue !== null || + mainFrame.parentRelation !== "root" ) - throw screenshotOriginRefusal(); - const initialFrame = recordValue( - recordValue(recordValue(before)?.frameTree)?.frame, - ); + throw new BrowserObservationError( + "capture_web_screenshot", + "protocol_error", + ); const navigation = { changed: false, leftScope: false, invalidIdentity: false, - frameId: mainFrame.frame_id, + frameId: mainFrame.frameId, url: beforeUrl, loaderId: cdpStringValue(initialFrame?.loaderId), completeness: new CdpCaptureCompleteness(), @@ -99,7 +99,8 @@ export const captureCdpScreenshot = async ( observeScreenshotNavigation(event, origins, navigation); }); const assertCurrentDocument = () => { - if (navigation.leftScope) throw screenshotOriginRefusal(); + if (navigation.leftScope) + throw cdpOriginScopeRefusal("capture_web_screenshot"); if (navigation.invalidIdentity) throw new BrowserObservationError("inspect_web_page", "protocol_error"); if (navigation.changed) @@ -143,14 +144,15 @@ export const captureCdpScreenshot = async ( removeListener(); } assertCurrentDocument(); - if (navigation.leftScope) throw screenshotOriginRefusal(); + if (navigation.leftScope) + throw cdpOriginScopeRefusal("capture_web_screenshot"); const afterObservation = observeMainFrameUrl(after); const afterFacts = classifyCdpFrameUrl(afterObservation, origins); if ( afterFacts.scope === "outside_policy" || afterFacts.scope === "unsupported" ) - throw screenshotOriginRefusal(); + throw cdpOriginScopeRefusal("capture_web_screenshot"); if (navigation.invalidIdentity || afterObservation.kind !== "observed") throw new BrowserObservationError("inspect_web_page", "protocol_error"); const afterUrl = afterFacts.url; diff --git a/src/browser/CdpWebMcpDiscovery.ts b/src/browser/CdpWebMcpDiscovery.ts index bef9865021..b256193a8b 100644 --- a/src/browser/CdpWebMcpDiscovery.ts +++ b/src/browser/CdpWebMcpDiscovery.ts @@ -15,6 +15,7 @@ import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js"; import type { CdpConnection, CdpEvent } from "./CdpConnection.js"; import { CdpCaptureCompleteness } from "./CdpCaptureCompleteness.js"; import { BrowserObservationError } from "../domain/browserObservationError.js"; +import { cdpOriginScopeRefusal } from "./CdpOriginScopeError.js"; import { allowedSanitizedUrl, delayWithCancellation, @@ -377,7 +378,7 @@ const requiredDiscoveryFrameUrl = ( const observed = observeMainFrameUrl(result); const facts = classifyCdpFrameUrl(observed, origins); if (facts.scope === "outside_policy" || facts.scope === "unsupported") - throw new BrowserObservationError("inspect_web_page", "target_not_allowed"); + throw cdpOriginScopeRefusal("discover_webmcp_tools"); if (observed.kind !== "observed") throw new BrowserObservationError("inspect_web_page", "protocol_error"); if (facts.url === undefined) diff --git a/src/browser/execution/CdpRuntimeSession.ts b/src/browser/execution/CdpRuntimeSession.ts index 09aa131e31..5566515d21 100644 --- a/src/browser/execution/CdpRuntimeSession.ts +++ b/src/browser/execution/CdpRuntimeSession.ts @@ -22,6 +22,7 @@ import { import type { z } from "zod"; import { authorizeCdpTarget } from "../CdpAuthorizedTarget.js"; import { authorizedMainFrame } from "../CdpAuthorizedMainFrame.js"; +import { cdpOriginScopeRefusal } from "../CdpOriginScopeError.js"; import { discoverCdpEndpoint, type CdpEndpointDiscovery, @@ -339,9 +340,8 @@ const runtimeFrameUrl = ( detail?: string, ) => { if (frame.scope === "outside_policy" || frame.scope === "unsupported") - throw new BrowserObservationError( + throw cdpOriginScopeRefusal( operation, - "target_not_allowed", detail === undefined ? {} : { detail }, ); if (frame.url === undefined || frame.origin === null) diff --git a/tests/boundary/browser/cdpAuthorizedMainFrameRetry.test.ts b/tests/boundary/browser/cdpAuthorizedMainFrameRetry.test.ts index afa7057c07..da0a0e72a6 100644 --- a/tests/boundary/browser/cdpAuthorizedMainFrameRetry.test.ts +++ b/tests/boundary/browser/cdpAuthorizedMainFrameRetry.test.ts @@ -4,6 +4,7 @@ import { CdpWebRuntimeProvider } from "../../../src/browser/execution/CdpWebRunt import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js"; import { observeWebSessionInputSchema } from "../../../src/domain/browserSession.js"; import { observeWebExecutionInputSchema } from "../../../src/domain/webExecution.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { startRuntimeBrowser } from "../../fixtures/webRuntime.js"; import type { FakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { trackBrowser } from "./cdpBrowserProvider.support.js"; @@ -101,10 +102,18 @@ it.each( }, }); trackBrowser(browser); - expect(await run(browser, operation)).toMatchObject({ + const result = await run(browser, operation); + expect(result).toMatchObject({ ok: false, error: { reason: foreign ? "target_not_allowed" : "protocol_error" }, }); + if (foreign && !result.ok) + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "target_unavailable", + message: expect.stringContaining("permitted HTTP(S) origin scope"), + remediation: { action: expect.stringContaining("allowed_origins") }, + details: { reason: "target_not_allowed" }, + }); expect(reads).toBe(1); }, ); @@ -123,10 +132,15 @@ it("bounds unsupported malformed frame retries without authorizing execution", a }, }); trackBrowser(browser); - expect(await run(browser, "runtime")).toMatchObject({ + const result = await run(browser, "runtime"); + expect(result).toMatchObject({ ok: false, error: { reason: "target_not_allowed" }, }); + if (result.ok) throw new Error("Expected unresolved frame refusal"); + expect(JSON.stringify(projectAnalysisError(result.error))).not.toContain( + "outside the permitted HTTP(S) origin scope", + ); expect(reads).toBe(80); expect(browser.commands.map(({ method }) => method)).not.toContain( "Debugger.enable", diff --git a/tests/boundary/browser/cdpCaptureInvalidation.test.ts b/tests/boundary/browser/cdpCaptureInvalidation.test.ts index 06ea3343b1..66b6eb6fcd 100644 --- a/tests/boundary/browser/cdpCaptureInvalidation.test.ts +++ b/tests/boundary/browser/cdpCaptureInvalidation.test.ts @@ -5,6 +5,57 @@ import { projectAnalysisError } from "../../../src/domain/analysisErrorProjectio import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { frameNavigated, trackBrowser } from "./cdpBrowserProvider.support.js"; +it.each([ + { method: "Network.enable", sourceFails: false }, + { method: "DOMSnapshot.captureSnapshot", sourceFails: false }, + { method: "DOMSnapshot.captureSnapshot", sourceFails: true }, +])( + "explains observed origin refusal during $method with source failure $sourceFails", + async ({ method: stage, sourceFails }) => { + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: [stage], + commandEvents: (command) => + command.method === stage + ? [ + frameNavigated(command, { + id: "frame-main", + loaderId: "outside-document", + url: "https://outside.example/page", + }), + ] + : undefined, + commandError: ({ method }) => + sourceFails && method === "Debugger.getScriptSource" + ? { code: -32000, message: "No script for id" } + : undefined, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().inspectPage( + inspectWebPageInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + observation_ms: 0, + include_script_sources: true, + }), + ); + if (result.ok) throw new Error("Expected observed origin refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "target_unavailable", + message: expect.stringContaining("permitted HTTP(S) origin scope"), + remediation: { action: expect.stringContaining("allowed_origins") }, + details: { + operation: "inspect_web_page", + reason: "target_not_allowed", + }, + }); + if (sourceFails) + expect(result.error.cause).toMatchObject({ reason: "protocol_error" }); + expect(browser.commands.map(({ method }) => method)).toContain( + "Target.detachFromTarget", + ); + }, +); + it.each([ { changed: true, sourceFails: true, reason: "target_changed" }, { changed: false, sourceFails: true, reason: "protocol_error" }, diff --git a/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts b/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts index 3d4887a2c2..c447cc3136 100644 --- a/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts +++ b/tests/boundary/browser/cdpScreenshotDocumentIdentity.test.ts @@ -144,6 +144,95 @@ describe("screenshot authorization during metrics reads", () => { ); }); +describe("screenshot initial root identity", () => { + it.each( + [ + { label: "missing", identity: {} }, + { label: "wrong type", identity: { id: 7 } }, + { label: "empty", identity: { id: "" } }, + { label: "parented", identity: { id: "frame-main", parentId: "parent" } }, + { + label: "malformed parent", + identity: { id: "frame-main", parentId: null }, + }, + { + label: "self parent", + identity: { id: "frame-main", parentId: "frame-main" }, + }, + ].flatMap(({ label, identity }) => + [false, true].map((withChild) => ({ label, identity, withChild })), + ), + )( + "rejects $label root identity without borrowing a child ($withChild)", + async ({ identity, withChild }) => { + const { browser, result } = await captureWithFake({ + commandResult: ({ method }, origin) => + method === "Page.getFrameTree" + ? { + frameTree: { + frame: { ...identity, url: origin + "/app" }, + ...(withChild + ? { + childFrames: [ + { + frame: { + id: "frame-child", + parentId: "frame-main", + url: origin + "/child", + }, + }, + ], + } + : {}), + }, + } + : undefined, + }); + expect(result).toMatchObject({ + ok: false, + error: { reason: "protocol_error" }, + }); + if (result.ok) throw new Error("Expected malformed root refusal"); + const projection = projectAnalysisError(result.error); + expect(projection.message).not.toContain( + "outside the permitted HTTP(S) origin scope", + ); + expect(projection.remediation.action).not.toContain("allowed_origins"); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Page.getLayoutMetrics", + ); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Page.captureScreenshot", + ); + await expect.poll(() => browser.activeConnectionCount()).toBe(0); + }, + ); + + it("retains a known foreign origin refusal when the root ID is malformed", async () => { + const { browser, result } = await captureWithFake({ + commandResult: ({ method }) => + method === "Page.getFrameTree" + ? { + frameTree: { + frame: { id: 7, url: "https://outside.example/page" }, + }, + } + : undefined, + }); + if (result.ok) throw new Error("Expected known origin refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + message: expect.stringContaining( + "outside the permitted HTTP(S) origin scope", + ), + remediation: { action: expect.stringContaining("allowed_origins") }, + details: { reason: "target_not_allowed" }, + }); + expect(browser.commands.map(({ method }) => method)).not.toContain( + "Page.captureScreenshot", + ); + }); +}); + describe("screenshot viewport freshness", () => { it.each([ { phase: "normal capture", stage: 2, imageSize: 4, captures: 1 }, @@ -763,6 +852,7 @@ describe("screenshot frame identity transitions", () => { it("detects a same-URL root replacement and scopes later events to its new identity", async () => { const { result } = await captureWithFake({ + eventsBeforeReply: ["Page.captureScreenshot"], commandEvents: (command, origin) => command.method === "Page.captureScreenshot" ? [ diff --git a/tests/boundary/browser/cdpWebMcpCoverage.test.ts b/tests/boundary/browser/cdpWebMcpCoverage.test.ts index 46058d309d..c8dd0e8c6d 100644 --- a/tests/boundary/browser/cdpWebMcpCoverage.test.ts +++ b/tests/boundary/browser/cdpWebMcpCoverage.test.ts @@ -2,9 +2,54 @@ import { expect, it } from "vitest"; import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js"; import { discoverWebMcpToolsInputSchema } from "../../../src/domain/webMcpDiscovery.js"; import { digestCanonicalValue } from "../../../src/domain/canonicalDigest.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { trackBrowser } from "./cdpBrowserProvider.support.js"; +it.each(["initial", "final"])( + "explains WebMCP %s current-document origin refusal", + async (stage) => { + const browser = await startFakeCdpBrowser({ + webMcpTools: true, + commandResult: ({ method }, origin, frameReads) => + method === "Page.getFrameTree" + ? { + frameTree: { + frame: { + id: "frame-main", + url: + stage === "initial" || frameReads > 1 + ? "https://outside.example/page" + : origin + "/app", + }, + }, + } + : undefined, + }); + trackBrowser(browser); + const result = await new CdpBrowserProvider().discoverWebMcpTools( + discoverWebMcpToolsInputSchema.parse({ + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + observation_ms: 0, + }), + ); + if (result.ok) throw new Error("Expected WebMCP origin refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "target_unavailable", + message: expect.stringContaining("permitted HTTP(S) origin scope"), + remediation: { action: expect.stringContaining("allowed_origins") }, + details: { + operation: "discover_webmcp_tools", + reason: "target_not_allowed", + }, + }); + expect( + browser.commands.some(({ method }) => method === "WebMCP.enable"), + ).toBe(stage === "final"); + }, +); + it.each([false, true])( "accepts optional WebMCP input schema (declared=%s)", async (declared) => { diff --git a/tests/boundary/browser/cdpWebRuntime.test.ts b/tests/boundary/browser/cdpWebRuntime.test.ts index db44df1868..da64bd09dc 100644 --- a/tests/boundary/browser/cdpWebRuntime.test.ts +++ b/tests/boundary/browser/cdpWebRuntime.test.ts @@ -9,6 +9,7 @@ import { startRuntimeBrowser, } from "../../fixtures/webRuntime.js"; import type { FakeOptions } from "../../fixtures/fakeCdpBrowser.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; const fixture = async (options: FakeOptions = {}) => { const browser = await startRuntimeBrowser(options); @@ -566,6 +567,42 @@ describe("runtime document identity and authorization failures", () => { ? "observe_web_execution" : "inspect_web_event_listeners", }); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "target_unavailable", + message: expect.stringContaining("permitted HTTP(S) origin scope"), + remediation: { action: expect.stringContaining("allowed_origins") }, + }); }, ); + + it("keeps runtime document-scope recovery when the producer retains the same document identity", async () => { + const { provider, listeners } = await fixture({ + commandResult: ({ method }, origin, frameReads) => + method === "Page.getFrameTree" + ? { + frameTree: { + frame: { + id: "runtime-main", + loaderId: "document-1", + url: + frameReads === 1 + ? origin + "/app" + : "https://outside.example/page", + }, + }, + } + : undefined, + }); + const result = await provider.inspectEventListeners(listeners); + if (result.ok) throw new Error("Expected runtime document-scope refusal"); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "target_unavailable", + message: expect.stringContaining("moved to an unselected origin"), + remediation: { action: expect.stringContaining("allowed_origins") }, + details: { + operation: "inspect_web_event_listeners", + reason: "target_not_allowed", + }, + }); + }); });