mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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.",
|
||||
});
|
||||
@@ -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> => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user