fix(browser): preserve observed document invalidation and scope recovery

This commit is contained in:
morluto committed 2026-10-24 03:11:06 +08:00
1 parent 1275817c75
commit 590bab9ada
6 files changed
+140 -15

No files matched your search

+6
View File
@@ -368,6 +368,12 @@ are reported in the result.
Call `list_browser_targets` first because target IDs are browser-instance-specific. REA rechecks the selected target's current type and origin immediately before attaching. Without an origin filter, discovery lists all eligible pages; an individual capture defaults to the selected page's current origin.
REA establishes a main-frame origin boundary before enabling Runtime, Debugger, or Network observation, rechecks it before document capture, and validates it again afterward. If the main frame navigates during final capture, REA discards the mixed result and returns `target_changed`.
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 web-session timeline uses CDP monotonic seconds where the event producer
provides a comparable timestamp; absent or invalid timestamps are `null` rather
+35 -5
View File
@@ -2,6 +2,8 @@ import type { InspectWebPageInput } from "../domain/browserObservation.js";
import type { WebPageInspection } from "../domain/browserObservationSchemas.js";
import type { ProgressReporter } from "../application/ProgressReporter.js";
import { BrowserObservationError } from "../domain/browserObservationError.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import type { BrowserObservationOperation } from "../domain/browserObservationErrors.js";
import { stableWebResources } from "../domain/webInventory.js";
import type { CdpEndpointDiscovery, CdpEndpointTarget } from "./CdpEndpoint.js";
@@ -101,11 +103,42 @@ export const capturePage = async (
};
try {
return await captureAuthorizedPage(state);
} catch (cause: unknown) {
// A navigation can invalidate an in-flight script ID or another document
// read. Report the observed capture invalidation before its stale-read
// symptom, while preserving caller cancellation.
if (
context.signal?.aborted !== true &&
!(cause instanceof AnalysisCancelledError) &&
!(cause instanceof AnalysisError && cause.userCategory === "cancelled")
)
assertObservedCaptureValid(state, cause);
throw cause;
} finally {
removeListener();
}
};
const assertObservedCaptureValid = (
state: CaptureState,
cause?: unknown,
): void => {
const reason = state.events.unknownRootIdentity
? "protocol_error"
: state.events.originViolation
? "target_not_allowed"
: state.events.navigationDuringCapture
? "target_changed"
: undefined;
if (reason !== undefined) {
if (cause instanceof BrowserObservationError && cause.reason === reason)
throw cause;
throw new BrowserObservationError(state.context.operation, reason, {
cause,
});
}
};
const captureAuthorizedPage = async (
state: CaptureState,
): Promise<CapturedPage> => {
@@ -175,11 +208,8 @@ const captureAuthorizedPage = async (
operation: context.operation,
});
const completedUrl = mainFrameUrl(completedFrameResult) ?? "";
if (state.events.unknownRootIdentity)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
if (state.events.originViolation)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
if (state.events.navigationDuringCapture || completedUrl !== attachedUrl)
assertObservedCaptureValid(state);
if (completedUrl !== attachedUrl)
throw new BrowserObservationError("inspect_web_page", "target_changed");
// Body IDs were enumerated before asynchronous CDP reads. Events remain
// enabled through the final capture, so account for eligible responses that
+13 -10
View File
@@ -34,6 +34,14 @@ 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,
@@ -63,7 +71,7 @@ export const captureCdpScreenshot = async (
beforeFacts.scope === "outside_policy" ||
beforeFacts.scope === "unsupported"
)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
throw screenshotOriginRefusal();
if (beforeObservation.kind !== "observed")
throw new BrowserObservationError("inspect_web_page", "protocol_error");
const beforeUrl = beforeFacts.url;
@@ -73,7 +81,7 @@ export const captureCdpScreenshot = async (
beforeFacts.origin === null ||
mainFrame === undefined
)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
throw screenshotOriginRefusal();
const initialFrame = recordValue(
recordValue(recordValue(before)?.frameTree)?.frame,
);
@@ -91,11 +99,7 @@ export const captureCdpScreenshot = async (
observeScreenshotNavigation(event, origins, navigation);
});
const assertCurrentDocument = () => {
if (navigation.leftScope)
throw new BrowserObservationError(
"inspect_web_page",
"target_not_allowed",
);
if (navigation.leftScope) throw screenshotOriginRefusal();
if (navigation.invalidIdentity)
throw new BrowserObservationError("inspect_web_page", "protocol_error");
if (navigation.changed)
@@ -139,15 +143,14 @@ export const captureCdpScreenshot = async (
removeListener();
}
assertCurrentDocument();
if (navigation.leftScope)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
if (navigation.leftScope) throw screenshotOriginRefusal();
const afterObservation = observeMainFrameUrl(after);
const afterFacts = classifyCdpFrameUrl(afterObservation, origins);
if (
afterFacts.scope === "outside_policy" ||
afterFacts.scope === "unsupported"
)
throw new BrowserObservationError("inspect_web_page", "target_not_allowed");
throw screenshotOriginRefusal();
if (navigation.invalidIdentity || afterObservation.kind !== "observed")
throw new BrowserObservationError("inspect_web_page", "protocol_error");
const afterUrl = afterFacts.url;
+2
View File
@@ -83,6 +83,8 @@ const observationFailureMessage = (
return `${operation} could not find the selected target at the debugging endpoint. Refresh target discovery for that endpoint and retry with a returned target ID.`;
if (reason === "target_session_detached")
return `${operation} lost its debugging session while the target may still exist. Refresh target discovery and attach to the selected target again.`;
if (reason === "target_changed")
return `${operation} could not retain a stable document because the target changed during observation. Retry after navigation settles; refresh target discovery if the selected page was replaced.`;
if (reason === "service_worker_blocked")
return `${operation} refused the selected page because it is controlled by or has a registered service worker while the service-worker policy is block. Set environment.service_workers to allow to permit observation, or remove the service worker registration through the browser.`;
if (reason === "disconnected")
@@ -0,0 +1,64 @@
import { expect, it } from "vitest";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { inspectWebPageInputSchema } from "../../../src/domain/browserObservation.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
import { frameNavigated, trackBrowser } from "./cdpBrowserProvider.support.js";
it.each([
{ changed: true, sourceFails: true, reason: "target_changed" },
{ changed: false, sourceFails: true, reason: "protocol_error" },
{ changed: false, sourceFails: false, reason: null },
])(
"preserves observed navigation before a stale source-read failure: $changed/$sourceFails",
async ({ changed, sourceFails, reason }) => {
const browser = await startFakeCdpBrowser({
eventsBeforeReply: ["DOMSnapshot.captureSnapshot"],
commandEvents: (command, origin) =>
changed && command.method === "DOMSnapshot.captureSnapshot"
? [
frameNavigated(command, {
id: "frame-main",
loaderId: "reloaded-document",
url: origin + "/reloaded",
}),
]
: 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 (reason === null) {
if (!result.ok) throw result.error;
expect(
result.value.scripts.items.some(({ source }) => source.included),
).toBe(true);
return;
}
expect(result).toMatchObject({ ok: false, error: { reason } });
if (result.ok) throw new Error("Expected a capture refusal");
if (changed) {
expect(projectAnalysisError(result.error)).toMatchObject({
code: "target_unavailable",
message: expect.stringContaining("target changed"),
remediation: {
action: expect.stringContaining("Retry after navigation settles"),
},
});
expect(result.error.cause).toMatchObject({ reason: "protocol_error" });
}
expect(browser.commands.map(({ method }) => method)).toContain(
"Target.detachFromTarget",
);
},
);
@@ -2,6 +2,7 @@ import { crc32 } from "node:zlib";
import { describe, expect, it, onTestFinished } from "vitest";
import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import {
startFakeCdpBrowser,
type FakeOptions,
@@ -39,6 +40,25 @@ const withCssViewport = (metrics: Readonly<Record<string, unknown>>) => ({
});
describe("screenshot authorization during metrics reads", () => {
it("explains current-document origin refusal without suggesting host installation repair", async () => {
const { browser, result } = await captureWithFake({
attachedFrameUrl: "https://outside.example.test/",
});
if (result.ok) throw new Error("Expected a scope refusal");
expect(projectAnalysisError(result.error)).toMatchObject({
code: "target_unavailable",
message: expect.stringContaining(
"outside the permitted HTTP(S) origin scope",
),
remediation: { action: expect.stringContaining("allowed_origins") },
details: { reason: "target_not_allowed" },
});
expect(
browser.commands.some(
({ method }) => method === "Page.captureScreenshot",
),
).toBe(false);
});
it.each([
{ stage: 1, expectedCaptures: 0 },
{ stage: 2, expectedCaptures: 1 },