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.
This commit is contained in:
morluto
2026-10-24 08:48:18 +08:00
parent f1dc595272
commit 09c2a2d241
13 changed files with 326 additions and 48 deletions
+11 -3
View File
@@ -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
+2 -1
View File
@@ -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;
+26 -15
View File
@@ -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,
};
};
+16
View File
@@ -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.",
});
+4 -2
View File
@@ -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<DomCapture> => {
+24 -22
View File
@@ -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;
+2 -1
View File
@@ -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)
+2 -2
View File
@@ -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)
@@ -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",
@@ -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" },
@@ -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"
? [
@@ -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) => {
@@ -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",
},
});
});
});