mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(browser): bound snapshot tree rendering depth (#119217)
Both snapshot renderers walked accessibility trees recursively with no default depth bound: the chrome-mcp builder only honored maxDepth when callers passed one, and the CDP renderRoleTree had no limit at all. A pathologically nested page could overflow the call stack and grow indent output quadratically before any output truncation ran. Add a generous hard depth bound (100) to both traversal paths.
This commit is contained in:
@@ -0,0 +1,126 @@
|
||||
// Browser tests cover CDP URL and error contracts.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
import {
|
||||
isDirectCdpWebSocketEndpoint,
|
||||
isWebSocketUrl,
|
||||
parseBrowserHttpUrl,
|
||||
} from "./cdp.helpers.js";
|
||||
import {
|
||||
BrowserCdpEndpointBlockedError,
|
||||
BrowserValidationError,
|
||||
toBrowserErrorResponse,
|
||||
} from "./errors.js";
|
||||
|
||||
describe("browser error mapping", () => {
|
||||
it("maps blocked browser targets to conflict responses", () => {
|
||||
const err = new Error(
|
||||
"Browser target is unavailable after SSRF policy blocked its navigation.",
|
||||
);
|
||||
err.name = "BlockedBrowserTargetError";
|
||||
|
||||
expect(toBrowserErrorResponse(err)).toEqual({
|
||||
status: 409,
|
||||
message: "Browser target is unavailable after SSRF policy blocked its navigation.",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves BrowserError mappings", () => {
|
||||
expect(toBrowserErrorResponse(new BrowserValidationError("bad input"))).toEqual({
|
||||
status: 400,
|
||||
message: "bad input",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes navigation-target SSRF policy errors without leaking raw policy details", () => {
|
||||
expect(
|
||||
toBrowserErrorResponse(
|
||||
new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"),
|
||||
),
|
||||
).toEqual({ status: 400, message: "browser navigation blocked by policy" });
|
||||
});
|
||||
|
||||
it("maps CDP endpoint policy blocks to a distinct endpoint-scoped message", () => {
|
||||
expect(toBrowserErrorResponse(new BrowserCdpEndpointBlockedError())).toEqual({
|
||||
status: 400,
|
||||
message: "browser endpoint blocked by policy",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWebSocketUrl", () => {
|
||||
it("recognizes ws and wss URLs", () => {
|
||||
expect(isWebSocketUrl("ws://127.0.0.1:9222")).toBe(true);
|
||||
expect(isWebSocketUrl("ws://example.com/devtools/browser/ABC")).toBe(true);
|
||||
expect(isWebSocketUrl("wss://connect.example.com")).toBe(true);
|
||||
expect(isWebSocketUrl("wss://connect.example.com?apiKey=abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects other protocols and invalid input", () => {
|
||||
expect(isWebSocketUrl("http://127.0.0.1:9222")).toBe(false);
|
||||
expect(isWebSocketUrl("https://production-sfo.browserless.io?token=abc")).toBe(false);
|
||||
expect(isWebSocketUrl("not-a-url")).toBe(false);
|
||||
expect(isWebSocketUrl("")).toBe(false);
|
||||
expect(isWebSocketUrl("ftp://example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDirectCdpWebSocketEndpoint", () => {
|
||||
it("recognizes ws/wss URLs with a /devtools/<kind>/<id> path", () => {
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/browser/ABC")).toBe(true);
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/page/42")).toBe(true);
|
||||
expect(isDirectCdpWebSocketEndpoint("wss://connect.example.com/devtools/browser/xyz")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isDirectCdpWebSocketEndpoint("wss://connect.example.com/devtools/browser/xyz?token=secret"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects bare ws/wss URLs that need discovery", () => {
|
||||
// Reproduces the configuration shape reported in #68027.
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("wss://browserless.example")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("wss://browserless.example/?token=abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-CDP paths, other protocols, and invalid input", () => {
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/json/version")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/other/path")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("http://127.0.0.1:9222/devtools/browser/ABC")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("https://host/devtools/browser/ABC")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("not-a-url")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseBrowserHttpUrl with WebSocket protocols", () => {
|
||||
it("applies default ports", () => {
|
||||
const secure = parseBrowserHttpUrl("wss://connect.example.com?apiKey=abc", "test");
|
||||
expect(secure.parsed.protocol).toBe("wss:");
|
||||
expect(secure.port).toBe(443);
|
||||
expect(secure.normalized).toContain("wss://connect.example.com");
|
||||
|
||||
const insecure = parseBrowserHttpUrl("ws://127.0.0.1/devtools", "test");
|
||||
expect(insecure.parsed.protocol).toBe("ws:");
|
||||
expect(insecure.port).toBe(80);
|
||||
});
|
||||
|
||||
it("preserves explicit and HTTP ports", () => {
|
||||
expect(parseBrowserHttpUrl("wss://connect.example.com:8443/path", "test").port).toBe(8443);
|
||||
expect(parseBrowserHttpUrl("http://127.0.0.1:9222", "test").port).toBe(9222);
|
||||
expect(parseBrowserHttpUrl("https://browserless.example?token=abc", "test").port).toBe(443);
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(() => parseBrowserHttpUrl("ftp://example.com", "test")).toThrow(
|
||||
"must be http(s) or ws(s)",
|
||||
);
|
||||
expect(() => parseBrowserHttpUrl("file:///etc/passwd", "test")).toThrow(
|
||||
"must be http(s) or ws(s)",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -7,28 +7,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { type WebSocket, WebSocketServer } from "ws";
|
||||
import { SsrFBlockedError } from "../infra/net/ssrf.js";
|
||||
import "../test-support/browser-security.mock.js";
|
||||
import {
|
||||
closeTrackedCdpTarget,
|
||||
isDirectCdpWebSocketEndpoint,
|
||||
isWebSocketUrl,
|
||||
parseBrowserHttpUrl as parseHttpUrl,
|
||||
resolveCdpTabOwnership,
|
||||
} from "./cdp.helpers.js";
|
||||
import { closeTrackedCdpTarget, resolveCdpTabOwnership } from "./cdp.helpers.js";
|
||||
import {
|
||||
createTargetViaCdp,
|
||||
normalizeCdpWsUrl,
|
||||
snapshotAria,
|
||||
snapshotRoleViaCdp,
|
||||
waitForCdpCommittedNavigationUrl,
|
||||
} from "./cdp.js";
|
||||
import {
|
||||
BrowserCdpEndpointBlockedError,
|
||||
BrowserValidationError,
|
||||
toBrowserErrorResponse,
|
||||
} from "./errors.js";
|
||||
import { BrowserCdpEndpointBlockedError } from "./errors.js";
|
||||
import { InvalidBrowserNavigationUrlError } from "./navigation-guard.js";
|
||||
|
||||
const BROWSER_ENDPOINT_BLOCKED_MESSAGE = "browser endpoint blocked by policy";
|
||||
const BROWSER_NAVIGATION_BLOCKED_MESSAGE = "browser navigation blocked by policy";
|
||||
const CDP_TEST_WS_MAX_PAYLOAD_BYTES = 1024 * 1024;
|
||||
|
||||
describe("cdp", () => {
|
||||
@@ -860,6 +849,29 @@ describe("cdp", () => {
|
||||
expect(snap.nodes[1]?.depth).toBe(1);
|
||||
});
|
||||
|
||||
it("hard-bounds CDP role rendering above a requested depth", async () => {
|
||||
const nodes = Array.from({ length: 1_000 }, (_value, index) => ({
|
||||
nodeId: String(index),
|
||||
role: { value: index === 0 ? "RootWebArea" : "generic" },
|
||||
name: { value: `n${index}` },
|
||||
childIds: index + 1 < 1_000 ? [String(index + 1)] : [],
|
||||
}));
|
||||
const wsPort = await startWsServerWithMessages((msg, socket) => {
|
||||
if (msg.method === "Accessibility.getFullAXTree") {
|
||||
socket.send(JSON.stringify({ id: msg.id, result: { nodes } }));
|
||||
}
|
||||
});
|
||||
|
||||
const snap = await snapshotRoleViaCdp({
|
||||
wsUrl: `ws://127.0.0.1:${wsPort}`,
|
||||
options: { maxDepth: 50_000 },
|
||||
});
|
||||
expect(snap.snapshot).toContain("[...TRUNCATED - accessibility tree too deep]");
|
||||
const roleLines = snap.snapshot.split("\n").filter((line) => line.trimStart().startsWith("-"));
|
||||
expect(roleLines).toHaveLength(101);
|
||||
expect(snap.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes loopback websocket URLs for remote CDP hosts", () => {
|
||||
const normalized = normalizeCdpWsUrl(
|
||||
"ws://127.0.0.1:9222/devtools/browser/ABC",
|
||||
@@ -937,134 +949,6 @@ describe("cdp", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("browser error mapping", () => {
|
||||
it("maps blocked browser targets to conflict responses", () => {
|
||||
const err = new Error(
|
||||
"Browser target is unavailable after SSRF policy blocked its navigation.",
|
||||
);
|
||||
err.name = "BlockedBrowserTargetError";
|
||||
|
||||
expect(toBrowserErrorResponse(err)).toEqual({
|
||||
status: 409,
|
||||
message: "Browser target is unavailable after SSRF policy blocked its navigation.",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves BrowserError mappings", () => {
|
||||
expect(toBrowserErrorResponse(new BrowserValidationError("bad input"))).toEqual({
|
||||
status: 400,
|
||||
message: "bad input",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes navigation-target SSRF policy errors without leaking raw policy details", () => {
|
||||
expect(
|
||||
toBrowserErrorResponse(
|
||||
new SsrFBlockedError("Blocked hostname or private/internal/special-use IP address"),
|
||||
),
|
||||
).toEqual({
|
||||
status: 400,
|
||||
message: BROWSER_NAVIGATION_BLOCKED_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
it("maps CDP endpoint policy blocks to a distinct endpoint-scoped message", () => {
|
||||
expect(toBrowserErrorResponse(new BrowserCdpEndpointBlockedError())).toEqual({
|
||||
status: 400,
|
||||
message: BROWSER_ENDPOINT_BLOCKED_MESSAGE,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("isWebSocketUrl", () => {
|
||||
it("returns true for ws:// URLs", () => {
|
||||
expect(isWebSocketUrl("ws://127.0.0.1:9222")).toBe(true);
|
||||
expect(isWebSocketUrl("ws://example.com/devtools/browser/ABC")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns true for wss:// URLs", () => {
|
||||
expect(isWebSocketUrl("wss://connect.example.com")).toBe(true);
|
||||
expect(isWebSocketUrl("wss://connect.example.com?apiKey=abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for http:// and https:// URLs", () => {
|
||||
expect(isWebSocketUrl("http://127.0.0.1:9222")).toBe(false);
|
||||
expect(isWebSocketUrl("https://production-sfo.browserless.io?token=abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for invalid or non-URL strings", () => {
|
||||
expect(isWebSocketUrl("not-a-url")).toBe(false);
|
||||
expect(isWebSocketUrl("")).toBe(false);
|
||||
expect(isWebSocketUrl("ftp://example.com")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDirectCdpWebSocketEndpoint", () => {
|
||||
it("returns true for ws/wss URLs with a /devtools/<kind>/<id> path", () => {
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/browser/ABC")).toBe(true);
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/page/42")).toBe(true);
|
||||
expect(isDirectCdpWebSocketEndpoint("wss://connect.example.com/devtools/browser/xyz")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(
|
||||
isDirectCdpWebSocketEndpoint("wss://connect.example.com/devtools/browser/xyz?token=secret"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for bare ws/wss URLs without a /devtools/ path (needs discovery)", () => {
|
||||
// Reproduces the configuration shape reported in #68027.
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("wss://browserless.example")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("wss://browserless.example/?token=abc")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for ws URLs whose path is not /devtools/*", () => {
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/json/version")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/devtools/")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("ws://127.0.0.1:9222/other/path")).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false for http/https URLs, invalid URLs, and empty strings", () => {
|
||||
expect(isDirectCdpWebSocketEndpoint("http://127.0.0.1:9222/devtools/browser/ABC")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("https://host/devtools/browser/ABC")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("not-a-url")).toBe(false);
|
||||
expect(isDirectCdpWebSocketEndpoint("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseHttpUrl with WebSocket protocols", () => {
|
||||
it("accepts wss:// URLs and defaults to port 443", () => {
|
||||
const result = parseHttpUrl("wss://connect.example.com?apiKey=abc", "test");
|
||||
expect(result.parsed.protocol).toBe("wss:");
|
||||
expect(result.port).toBe(443);
|
||||
expect(result.normalized).toContain("wss://connect.example.com");
|
||||
});
|
||||
|
||||
it("accepts ws:// URLs and defaults to port 80", () => {
|
||||
const result = parseHttpUrl("ws://127.0.0.1/devtools", "test");
|
||||
expect(result.parsed.protocol).toBe("ws:");
|
||||
expect(result.port).toBe(80);
|
||||
});
|
||||
|
||||
it("preserves explicit ports in wss:// URLs", () => {
|
||||
const result = parseHttpUrl("wss://connect.example.com:8443/path", "test");
|
||||
expect(result.port).toBe(8443);
|
||||
});
|
||||
|
||||
it("still accepts http:// and https:// URLs", () => {
|
||||
const http = parseHttpUrl("http://127.0.0.1:9222", "test");
|
||||
expect(http.port).toBe(9222);
|
||||
const https = parseHttpUrl("https://browserless.example?token=abc", "test");
|
||||
expect(https.port).toBe(443);
|
||||
});
|
||||
|
||||
it("rejects unsupported protocols", () => {
|
||||
expect(() => parseHttpUrl("ftp://example.com", "test")).toThrow("must be http(s) or ws(s)");
|
||||
expect(() => parseHttpUrl("file:///etc/passwd", "test")).toThrow("must be http(s) or ws(s)");
|
||||
});
|
||||
});
|
||||
const proxyEnvKeys = [
|
||||
"ALL_PROXY",
|
||||
"all_proxy",
|
||||
|
||||
@@ -28,6 +28,10 @@ import {
|
||||
} from "./cdp.helpers.js";
|
||||
import { assertBrowserNavigationAllowed, withBrowserNavigationPolicy } from "./navigation-guard.js";
|
||||
import { finalizeRoleSnapshot, type RoleSnapshotIdentityMode } from "./pw-role-snapshot.js";
|
||||
import {
|
||||
appendRoleSnapshotDepthTruncationMarker,
|
||||
ROLE_SNAPSHOT_MAX_DEPTH,
|
||||
} from "./snapshot-depth-limit.js";
|
||||
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
|
||||
|
||||
export { appendCdpPath } from "./cdp.helpers.js";
|
||||
@@ -533,9 +537,6 @@ function buildRoleTree(nodes: RawAXNode[]): { tree: RoleTreeNode[]; roots: numbe
|
||||
|
||||
function shouldIncludeRoleNode(node: RoleTreeNode, options: CdpRoleSnapshotOptions): boolean {
|
||||
const role = node.role.toLowerCase();
|
||||
if (options.maxDepth !== undefined && node.depth > options.maxDepth) {
|
||||
return false;
|
||||
}
|
||||
if (options.interactive) {
|
||||
return INTERACTIVE_ROLES.has(role) || role === "iframe" || Boolean(node.cursorInfo);
|
||||
}
|
||||
@@ -572,14 +573,23 @@ function renderRoleTree(
|
||||
index: number,
|
||||
output: string[],
|
||||
options: CdpRoleSnapshotOptions,
|
||||
state: { truncated: boolean },
|
||||
indentOffset = 0,
|
||||
): void {
|
||||
const node = tree[index];
|
||||
if (!node) {
|
||||
return;
|
||||
}
|
||||
if (options.maxDepth !== undefined && node.depth > options.maxDepth) {
|
||||
return;
|
||||
}
|
||||
const effectiveDepth = Math.max(0, node.depth + indentOffset);
|
||||
if (effectiveDepth > ROLE_SNAPSHOT_MAX_DEPTH) {
|
||||
state.truncated = true;
|
||||
return;
|
||||
}
|
||||
if (shouldIncludeRoleNode(node, options)) {
|
||||
const indent = " ".repeat(Math.max(0, node.depth + indentOffset));
|
||||
const indent = " ".repeat(effectiveDepth);
|
||||
const name = node.name ? ` "${escapeRoleSnapshotValue(node.name)}"` : "";
|
||||
const ref = node.ref ? ` [ref=${node.ref}]` : "";
|
||||
const nth = node.nth !== undefined && node.nth > 0 ? ` [nth=${node.nth}]` : "";
|
||||
@@ -590,7 +600,7 @@ function renderRoleTree(
|
||||
);
|
||||
}
|
||||
for (const child of node.children) {
|
||||
renderRoleTree(tree, child, output, options, indentOffset);
|
||||
renderRoleTree(tree, child, output, options, state, indentOffset);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,6 +788,7 @@ async function buildCdpRoleSnapshot(params: {
|
||||
}): Promise<{
|
||||
lines: string[];
|
||||
refs: Record<string, CdpRoleRef>;
|
||||
truncated: boolean;
|
||||
}> {
|
||||
const res = (await params.send(
|
||||
"Accessibility.getFullAXTree",
|
||||
@@ -866,8 +877,9 @@ async function buildCdpRoleSnapshot(params: {
|
||||
}
|
||||
|
||||
const lines: string[] = [];
|
||||
const renderState = { truncated: false };
|
||||
for (const root of roots) {
|
||||
renderRoleTree(tree, root, lines, params.options);
|
||||
renderRoleTree(tree, root, lines, params.options, renderState);
|
||||
}
|
||||
|
||||
if (params.recurseIframes) {
|
||||
@@ -883,7 +895,11 @@ async function buildCdpRoleSnapshot(params: {
|
||||
frameId: iframe.frameId,
|
||||
recurseIframes: false,
|
||||
}).catch(() => null);
|
||||
if (!child?.lines.length) {
|
||||
if (!child) {
|
||||
continue;
|
||||
}
|
||||
renderState.truncated ||= child.truncated;
|
||||
if (!child.lines.length) {
|
||||
continue;
|
||||
}
|
||||
Object.assign(refs, child.refs);
|
||||
@@ -894,6 +910,7 @@ async function buildCdpRoleSnapshot(params: {
|
||||
return {
|
||||
lines,
|
||||
refs,
|
||||
truncated: renderState.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -923,15 +940,20 @@ export async function snapshotRoleViaCdp(opts: {
|
||||
recurseIframes: true,
|
||||
nextRef: { value: 1 },
|
||||
});
|
||||
const snapshot =
|
||||
const renderedSnapshot =
|
||||
built.lines.join("\n").trim() ||
|
||||
(opts.options?.interactive ? "(no interactive elements)" : "(empty page)");
|
||||
return finalizeRoleSnapshot({
|
||||
snapshot,
|
||||
const finalized = finalizeRoleSnapshot({
|
||||
snapshot: built.truncated
|
||||
? appendRoleSnapshotDepthTruncationMarker(renderedSnapshot)
|
||||
: renderedSnapshot,
|
||||
refs: built.refs,
|
||||
maxChars: opts.maxChars,
|
||||
delta: opts.delta,
|
||||
});
|
||||
return built.truncated && !finalized.truncated
|
||||
? { ...finalized, truncated: true }
|
||||
: finalized;
|
||||
},
|
||||
{ commandTimeoutMs: opts.timeoutMs ?? 5000 },
|
||||
);
|
||||
|
||||
@@ -158,7 +158,7 @@ export function wrapChromeMcpSnapshotRefs(
|
||||
clearChromeMcpSnapshotRefsForTarget(routing, targetId);
|
||||
const wrappedByUid = new Map<string, string>();
|
||||
|
||||
const visit = (node: ChromeMcpSnapshotNode): ChromeMcpSnapshotNode => {
|
||||
const wrapNode = (node: ChromeMcpSnapshotNode): ChromeMcpSnapshotNode => {
|
||||
const rawUid = normalizeOptionalString(node.id);
|
||||
let id: string | undefined;
|
||||
if (rawUid) {
|
||||
@@ -173,11 +173,46 @@ export function wrapChromeMcpSnapshotRefs(
|
||||
return {
|
||||
...node,
|
||||
...(id ? { id } : {}),
|
||||
...(node.children ? { children: node.children.map(visit) } : {}),
|
||||
...(node.children ? { children: [] } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
return visit(root);
|
||||
// Ref rewriting is the first traversal of external MCP output. Keep it
|
||||
// iterative so the renderer can own depth truncation and report that fact.
|
||||
let wrappedRoot: ChromeMcpSnapshotNode | undefined;
|
||||
const stack: Array<{
|
||||
source: ChromeMcpSnapshotNode;
|
||||
parent?: ChromeMcpSnapshotNode[];
|
||||
index?: number;
|
||||
}> = [{ source: root }];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
const wrapped = wrapNode(current.source);
|
||||
if (current.parent && current.index !== undefined) {
|
||||
current.parent[current.index] = wrapped;
|
||||
} else {
|
||||
wrappedRoot = wrapped;
|
||||
}
|
||||
const sourceChildren = current.source.children;
|
||||
if (!sourceChildren) {
|
||||
continue;
|
||||
}
|
||||
const wrappedChildren: ChromeMcpSnapshotNode[] = [];
|
||||
wrapped.children = wrappedChildren;
|
||||
for (let index = sourceChildren.length - 1; index >= 0; index -= 1) {
|
||||
const child = sourceChildren[index];
|
||||
if (child) {
|
||||
stack.push({ source: child, parent: wrappedChildren, index });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!wrappedRoot) {
|
||||
throw new Error("Chrome MCP snapshot did not contain a root node");
|
||||
}
|
||||
return wrappedRoot;
|
||||
}
|
||||
|
||||
export function resolveChromeMcpSnapshotRef(
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import {
|
||||
buildAiSnapshotFromChromeMcpSnapshot,
|
||||
type ChromeMcpSnapshotNode,
|
||||
flattenChromeMcpSnapshotToAriaResult,
|
||||
} from "./chrome-mcp.snapshot.js";
|
||||
// Route-facing Chrome MCP snapshot results preserve automatic truncation facts.
|
||||
import type { SnapshotAriaNode } from "./client.types.js";
|
||||
import type { RoleRefMap, RoleSnapshotOptions } from "./pw-role-snapshot.js";
|
||||
import { appendRoleSnapshotDepthTruncationMarker } from "./snapshot-depth-limit.js";
|
||||
|
||||
export function buildChromeMcpRouteSnapshot(params: {
|
||||
root: ChromeMcpSnapshotNode;
|
||||
options?: RoleSnapshotOptions;
|
||||
}): { snapshot: string; refs: RoleRefMap; truncated?: true } {
|
||||
const built = buildAiSnapshotFromChromeMcpSnapshot(params);
|
||||
return built.truncated
|
||||
? {
|
||||
...built,
|
||||
snapshot: appendRoleSnapshotDepthTruncationMarker(built.snapshot),
|
||||
truncated: true as const,
|
||||
}
|
||||
: built;
|
||||
}
|
||||
|
||||
export function flattenChromeMcpRouteSnapshot(
|
||||
root: ChromeMcpSnapshotNode,
|
||||
limit = 500,
|
||||
): { nodes: SnapshotAriaNode[]; truncated?: true } {
|
||||
return flattenChromeMcpSnapshotToAriaResult(root, limit);
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildAiSnapshotFromChromeMcpSnapshot,
|
||||
flattenChromeMcpSnapshotToAriaNodes,
|
||||
flattenChromeMcpSnapshotToAriaResult,
|
||||
} from "./chrome-mcp.snapshot.js";
|
||||
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
|
||||
import { finalizeRoleSnapshot } from "./pw-role-snapshot.js";
|
||||
import { appendSnapshotUrls } from "./snapshot-urls.js";
|
||||
|
||||
@@ -28,33 +29,35 @@ const snapshot = {
|
||||
|
||||
describe("chrome MCP snapshot conversion", () => {
|
||||
it("flattens structured snapshots into aria-style nodes", () => {
|
||||
const nodes = flattenChromeMcpSnapshotToAriaNodes(snapshot, 10);
|
||||
expect(nodes).toEqual([
|
||||
{
|
||||
ref: "root",
|
||||
role: "document",
|
||||
name: "Example",
|
||||
value: undefined,
|
||||
description: undefined,
|
||||
depth: 0,
|
||||
},
|
||||
{
|
||||
ref: "btn-1",
|
||||
role: "button",
|
||||
name: "Continue",
|
||||
value: undefined,
|
||||
description: undefined,
|
||||
depth: 1,
|
||||
},
|
||||
{
|
||||
ref: "txt-1",
|
||||
role: "textbox",
|
||||
name: "Email",
|
||||
value: "peter@example.com",
|
||||
description: undefined,
|
||||
depth: 1,
|
||||
},
|
||||
]);
|
||||
const result = flattenChromeMcpSnapshotToAriaResult(snapshot, 10);
|
||||
expect(result).toEqual({
|
||||
nodes: [
|
||||
{
|
||||
ref: "root",
|
||||
role: "document",
|
||||
name: "Example",
|
||||
value: undefined,
|
||||
description: undefined,
|
||||
depth: 0,
|
||||
},
|
||||
{
|
||||
ref: "btn-1",
|
||||
role: "button",
|
||||
name: "Continue",
|
||||
value: undefined,
|
||||
description: undefined,
|
||||
depth: 1,
|
||||
},
|
||||
{
|
||||
ref: "txt-1",
|
||||
role: "textbox",
|
||||
name: "Email",
|
||||
value: "peter@example.com",
|
||||
description: undefined,
|
||||
depth: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("builds AI snapshots that preserve Chrome MCP uids as refs", () => {
|
||||
@@ -112,4 +115,25 @@ describe("chrome MCP snapshot conversion", () => {
|
||||
visible: { role: "button", name: "Visible\n- button [ref=hidden]" },
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds traversal of pathologically deep snapshot trees", () => {
|
||||
// A page can nest DOM tens of thousands of levels deep; traversal must hit
|
||||
// the depth bound instead of overflowing the stack or exploding indents.
|
||||
let root: ChromeMcpSnapshotNode = { id: "leaf", role: "text", name: "leaf" };
|
||||
for (let index = 0; index < 50_000; index += 1) {
|
||||
root = { id: `n${index}`, role: "generic", name: `n${index}`, children: [root] };
|
||||
}
|
||||
|
||||
for (const options of [undefined, { maxDepth: 50_000 }]) {
|
||||
const built = buildAiSnapshotFromChromeMcpSnapshot({ root, options });
|
||||
expect(built.snapshot.length).toBeGreaterThan(0);
|
||||
expect(built.snapshot.split("\n").length).toBeLessThanOrEqual(101);
|
||||
expect(built.truncated).toBe(true);
|
||||
}
|
||||
|
||||
const flattened = flattenChromeMcpSnapshotToAriaResult(root);
|
||||
expect(flattened.nodes.length).toBeGreaterThan(0);
|
||||
expect(Math.max(...flattened.nodes.map((node) => node.depth))).toBeLessThanOrEqual(100);
|
||||
expect(flattened.truncated).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { SnapshotAriaNode } from "./client.types.js";
|
||||
import type { RoleRefMap, RoleSnapshotOptions } from "./pw-role-snapshot.js";
|
||||
import { ROLE_SNAPSHOT_MAX_DEPTH } from "./snapshot-depth-limit.js";
|
||||
import { CONTENT_ROLES, INTERACTIVE_ROLES, STRUCTURAL_ROLES } from "./snapshot-roles.js";
|
||||
|
||||
/** Structured snapshot node shape returned by chrome-devtools-mcp. */
|
||||
@@ -88,16 +89,22 @@ function registerRef(
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Flatten a Chrome MCP snapshot tree into OpenClaw ARIA-style nodes. */
|
||||
export function flattenChromeMcpSnapshotToAriaNodes(
|
||||
/** Build ARIA nodes while preserving whether a traversal ceiling omitted input. */
|
||||
export function flattenChromeMcpSnapshotToAriaResult(
|
||||
root: ChromeMcpSnapshotNode,
|
||||
limit = 500,
|
||||
): SnapshotAriaNode[] {
|
||||
): { nodes: SnapshotAriaNode[]; truncated?: true } {
|
||||
const boundedLimit = Math.max(1, Math.min(2000, Math.floor(limit)));
|
||||
const out: SnapshotAriaNode[] = [];
|
||||
let truncated = false;
|
||||
|
||||
const visit = (node: ChromeMcpSnapshotNode, depth: number) => {
|
||||
if (out.length >= boundedLimit) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
if (depth > ROLE_SNAPSHOT_MAX_DEPTH) {
|
||||
truncated = true;
|
||||
return;
|
||||
}
|
||||
const ref = normalizeSnapshotString(node.id);
|
||||
@@ -111,16 +118,18 @@ export function flattenChromeMcpSnapshotToAriaNodes(
|
||||
depth,
|
||||
});
|
||||
}
|
||||
for (const child of node.children ?? []) {
|
||||
const children = node.children ?? [];
|
||||
for (const [index, child] of children.entries()) {
|
||||
visit(child, depth + 1);
|
||||
if (out.length >= boundedLimit) {
|
||||
truncated ||= index + 1 < children.length;
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
visit(root, 0);
|
||||
return out;
|
||||
return truncated ? { nodes: out, truncated: true } : { nodes: out };
|
||||
}
|
||||
|
||||
/** Build a compact text snapshot and ref map from a Chrome MCP snapshot tree. */
|
||||
@@ -130,20 +139,28 @@ export function buildAiSnapshotFromChromeMcpSnapshot(params: {
|
||||
}): {
|
||||
snapshot: string;
|
||||
refs: RoleRefMap;
|
||||
truncated?: true;
|
||||
} {
|
||||
const refs: RoleRefMap = {};
|
||||
const tracker = createDuplicateTracker();
|
||||
const lines: string[] = [];
|
||||
const maxDepth = Math.min(
|
||||
params.options?.maxDepth ?? ROLE_SNAPSHOT_MAX_DEPTH,
|
||||
ROLE_SNAPSHOT_MAX_DEPTH,
|
||||
);
|
||||
const hardLimitApplied =
|
||||
params.options?.maxDepth === undefined || params.options.maxDepth >= ROLE_SNAPSHOT_MAX_DEPTH;
|
||||
let truncated = false;
|
||||
|
||||
const visit = (node: ChromeMcpSnapshotNode, depth: number) => {
|
||||
if (depth > maxDepth) {
|
||||
truncated ||= hardLimitApplied;
|
||||
return;
|
||||
}
|
||||
const role = normalizeRole(node);
|
||||
const name = normalizeSnapshotString(node.name);
|
||||
const value = normalizeSnapshotString(node.value);
|
||||
const description = normalizeSnapshotString(node.description);
|
||||
const maxDepth = params.options?.maxDepth;
|
||||
if (maxDepth !== undefined && depth > maxDepth) {
|
||||
return;
|
||||
}
|
||||
|
||||
const includeNode = shouldIncludeNode({ role, name, options: params.options });
|
||||
if (includeNode) {
|
||||
@@ -180,5 +197,6 @@ export function buildAiSnapshotFromChromeMcpSnapshot(params: {
|
||||
}
|
||||
}
|
||||
|
||||
return { snapshot: lines.join("\n"), refs };
|
||||
const result = { snapshot: lines.join("\n"), refs };
|
||||
return truncated ? { ...result, truncated: true } : result;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
uploadChromeMcpFile,
|
||||
withChromeMcpDocument,
|
||||
} from "./chrome-mcp.js";
|
||||
import type { ChromeMcpSnapshotNode } from "./chrome-mcp.snapshot.js";
|
||||
|
||||
type ToolCall = {
|
||||
name: string;
|
||||
@@ -973,6 +974,38 @@ describe("chrome MCP page parsing", () => {
|
||||
expect(clickedUids).toEqual(["1_2", "1_2"]);
|
||||
});
|
||||
|
||||
it("wraps deeply nested snapshot refs without recursive traversal", async () => {
|
||||
let root: ChromeMcpSnapshotNode = { id: "leaf", role: "text", name: "leaf" };
|
||||
for (let index = 0; index < 50_000; index += 1) {
|
||||
root = {
|
||||
id: `n${index}`,
|
||||
role: "generic",
|
||||
name: `n${index}`,
|
||||
children: [root],
|
||||
};
|
||||
}
|
||||
const session = createPageSession({
|
||||
pid: 141,
|
||||
pages: [{ id: 1, url: "https://a.example" }],
|
||||
onTool: (call) =>
|
||||
call.name === "take_snapshot" ? { structuredContent: { snapshot: root } } : undefined,
|
||||
});
|
||||
setChromeMcpSessionFactoryForTest(async () => session);
|
||||
|
||||
const [target] = await listChromeMcpTabs("chrome-live");
|
||||
let node = await takeChromeMcpSnapshot({
|
||||
profileName: "chrome-live",
|
||||
targetId: target?.targetId ?? "",
|
||||
});
|
||||
let depth = 0;
|
||||
while (node.children?.[0]) {
|
||||
node = node.children[0];
|
||||
depth += 1;
|
||||
}
|
||||
expect(depth).toBe(50_000);
|
||||
expect(node.id).toMatch(/^mcp-ref:/);
|
||||
});
|
||||
|
||||
it("unwraps current snapshot refs for every ref-scoped MCP adapter", async () => {
|
||||
const session = createPageSession({
|
||||
pid: 141,
|
||||
|
||||
@@ -40,6 +40,7 @@ export type BrowserActionPathResult = {
|
||||
labels?: boolean;
|
||||
labelsCount?: number;
|
||||
labelsSkipped?: number;
|
||||
truncated?: boolean;
|
||||
/**
|
||||
* Per-ref bounding boxes when labels=true. Coordinates are in the
|
||||
* captured image's space (viewport / fullpage / element-relative).
|
||||
|
||||
@@ -130,6 +130,7 @@ export type SnapshotResult =
|
||||
targetId: string;
|
||||
url: string;
|
||||
nodes: SnapshotAriaNode[];
|
||||
truncated?: boolean;
|
||||
blockedByDialog?: boolean;
|
||||
browserState?: unknown;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Browser tests cover agent.existing session plugin behavior.
|
||||
import { createRequireRecord } from "openclaw/plugin-sdk/test-fixtures";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChromeMcpSnapshotNode } from "../chrome-mcp.snapshot.js";
|
||||
import { EXISTING_SESSION_LIMITS } from "./existing-session-limits.js";
|
||||
import {
|
||||
createExistingSessionAgentSharedModule,
|
||||
@@ -20,7 +21,7 @@ const chromeMcpMocks = vi.hoisted(() => ({
|
||||
fillChromeMcpElement: vi.fn(async () => {}),
|
||||
navigateChromeMcpPage: vi.fn(async ({ url }: { url: string }) => ({ url })),
|
||||
takeChromeMcpScreenshot: vi.fn(async () => Buffer.from("png")),
|
||||
takeChromeMcpSnapshot: vi.fn(async () => ({
|
||||
takeChromeMcpSnapshot: vi.fn<() => Promise<ChromeMcpSnapshotNode>>(async () => ({
|
||||
id: "root",
|
||||
role: "document",
|
||||
name: "Example",
|
||||
@@ -270,6 +271,53 @@ describe("existing-session browser routes", () => {
|
||||
expect(renderParams.fn).not.toContain('"btn-2"');
|
||||
});
|
||||
|
||||
it("reports automatic Chrome MCP depth truncation through AI and ARIA routes", async () => {
|
||||
let root: ChromeMcpSnapshotNode = { id: "leaf", role: "text", name: "leaf" };
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
root = { id: `n${index}`, role: "generic", name: `n${index}`, children: [root] };
|
||||
}
|
||||
chromeMcpMocks.takeChromeMcpSnapshot
|
||||
.mockResolvedValueOnce(root)
|
||||
.mockResolvedValueOnce(root)
|
||||
.mockResolvedValueOnce(root);
|
||||
const handler = getSnapshotGetHandler();
|
||||
|
||||
const ai = createBrowserRouteResponse();
|
||||
await handler?.({ params: {}, query: { format: "ai" } }, ai.res);
|
||||
const aiBody = requireRecord(ai.body, "AI snapshot body");
|
||||
expect(aiBody.truncated).toBe(true);
|
||||
expect(aiBody.snapshot).toContain("[...TRUNCATED - accessibility tree too deep]");
|
||||
|
||||
const aria = createBrowserRouteResponse();
|
||||
await handler?.({ params: {}, query: { format: "aria" } }, aria.res);
|
||||
const ariaBody = requireRecord(aria.body, "ARIA snapshot body");
|
||||
expect(ariaBody.truncated).toBe(true);
|
||||
expect(ariaBody.nodes).toHaveLength(101);
|
||||
|
||||
const requestedDepth = createBrowserRouteResponse();
|
||||
await handler?.({ params: {}, query: { format: "ai", depth: "5" } }, requestedDepth.res);
|
||||
const requestedDepthBody = requireRecord(requestedDepth.body, "requested-depth snapshot body");
|
||||
expect(requestedDepthBody.truncated).toBeUndefined();
|
||||
expect(requestedDepthBody.snapshot).not.toContain("TRUNCATED");
|
||||
});
|
||||
|
||||
it("reports automatic Chrome MCP depth truncation on labeled screenshots", async () => {
|
||||
let root: ChromeMcpSnapshotNode = { id: "leaf", role: "text", name: "leaf" };
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
root = { id: `n${index}`, role: "generic", name: `n${index}`, children: [root] };
|
||||
}
|
||||
chromeMcpMocks.takeChromeMcpSnapshot.mockResolvedValueOnce(root);
|
||||
const handler = getSnapshotPostHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
|
||||
await handler?.({ params: {}, query: {}, body: { labels: true } }, response.res);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
const body = requireRecord(response.body, "labeled screenshot body");
|
||||
expect(body.labels).toBe(true);
|
||||
expect(body.truncated).toBe(true);
|
||||
});
|
||||
|
||||
it("allows ref screenshots for existing-session profiles", async () => {
|
||||
const handler = getSnapshotPostHandler();
|
||||
const response = createBrowserRouteResponse();
|
||||
|
||||
@@ -24,9 +24,9 @@ import {
|
||||
type ChromeMcpProfileOptions,
|
||||
} from "../chrome-mcp.js";
|
||||
import {
|
||||
buildAiSnapshotFromChromeMcpSnapshot,
|
||||
flattenChromeMcpSnapshotToAriaNodes,
|
||||
} from "../chrome-mcp.snapshot.js";
|
||||
buildChromeMcpRouteSnapshot,
|
||||
flattenChromeMcpRouteSnapshot,
|
||||
} from "../chrome-mcp.snapshot-result.js";
|
||||
import { DEFAULT_BROWSER_SCREENSHOT_TIMEOUT_MS } from "../constants.js";
|
||||
import {
|
||||
assertBrowserNavigationAllowed,
|
||||
@@ -204,6 +204,7 @@ async function saveNormalizedScreenshotResponse(params: {
|
||||
labels?: boolean;
|
||||
labelsCount?: number;
|
||||
labelsSkipped?: number;
|
||||
truncated?: boolean;
|
||||
annotations?: AnnotationItem[];
|
||||
}) {
|
||||
// Measure original dimensions BEFORE normalization so we can rescale
|
||||
@@ -232,6 +233,7 @@ async function saveNormalizedScreenshotResponse(params: {
|
||||
labels: params.labels,
|
||||
labelsCount: params.labelsCount,
|
||||
labelsSkipped: params.labelsSkipped,
|
||||
truncated: params.truncated,
|
||||
annotations,
|
||||
});
|
||||
}
|
||||
@@ -275,6 +277,7 @@ async function saveBrowserMediaResponse(params: {
|
||||
labels?: boolean;
|
||||
labelsCount?: number;
|
||||
labelsSkipped?: number;
|
||||
truncated?: boolean;
|
||||
annotations?: AnnotationItem[];
|
||||
}) {
|
||||
await ensureMediaDir();
|
||||
@@ -292,6 +295,7 @@ async function saveBrowserMediaResponse(params: {
|
||||
...(params.labels ? { labels: true } : {}),
|
||||
...(typeof params.labelsCount === "number" ? { labelsCount: params.labelsCount } : {}),
|
||||
...(typeof params.labelsSkipped === "number" ? { labelsSkipped: params.labelsSkipped } : {}),
|
||||
...(params.truncated ? { truncated: true } : {}),
|
||||
...(params.annotations && params.annotations.length > 0
|
||||
? { annotations: params.annotations }
|
||||
: {}),
|
||||
@@ -466,7 +470,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
}
|
||||
if (labels) {
|
||||
const snapshot = await takeChromeMcpSnapshot(operation);
|
||||
const built = buildAiSnapshotFromChromeMcpSnapshot({ root: snapshot });
|
||||
const built = buildChromeMcpRouteSnapshot({ root: snapshot });
|
||||
const labelResult = await renderChromeMcpLabels({
|
||||
...operation,
|
||||
refs: Object.keys(built.refs),
|
||||
@@ -486,6 +490,7 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
labels: true,
|
||||
labelsCount: labelResult.labels,
|
||||
labelsSkipped: labelResult.skipped,
|
||||
truncated: built.truncated,
|
||||
});
|
||||
} finally {
|
||||
await clearChromeMcpOverlay(operation);
|
||||
@@ -680,16 +685,17 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
};
|
||||
const snapshot = await takeChromeMcpSnapshot(operation);
|
||||
if (plan.format === "aria") {
|
||||
const flattened = flattenChromeMcpRouteSnapshot(snapshot, plan.limit);
|
||||
return res.json({
|
||||
ok: true,
|
||||
format: "aria",
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
nodes: flattenChromeMcpSnapshotToAriaNodes(snapshot, plan.limit),
|
||||
...flattened,
|
||||
});
|
||||
}
|
||||
const deltaState = createDeltaState();
|
||||
const built = buildAiSnapshotFromChromeMcpSnapshot({
|
||||
const built = buildChromeMcpRouteSnapshot({
|
||||
root: snapshot,
|
||||
options: {
|
||||
interactive: plan.interactive ?? undefined,
|
||||
@@ -706,11 +712,15 @@ export function registerBrowserAgentSnapshotRoutes(
|
||||
),
|
||||
}
|
||||
: built;
|
||||
const finalized = finalizeRoleSnapshot({
|
||||
const finalizedBase = finalizeRoleSnapshot({
|
||||
...builtWithUrls,
|
||||
maxChars: plan.resolvedMaxChars,
|
||||
delta: deltaState.delta,
|
||||
});
|
||||
const finalized =
|
||||
built.truncated && !finalizedBase.truncated
|
||||
? { ...finalizedBase, truncated: true }
|
||||
: finalizedBase;
|
||||
if (plan.labels) {
|
||||
const refs = Object.keys(finalized.refs);
|
||||
const labelResult = await renderChromeMcpLabels({
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Hard ceiling for recursive accessibility-tree rendering. This bounds both
|
||||
* call-stack use and quadratic indentation growth on external tree input.
|
||||
*/
|
||||
export const ROLE_SNAPSHOT_MAX_DEPTH = 100;
|
||||
|
||||
const ROLE_SNAPSHOT_DEPTH_TRUNCATION_MARKER = "[...TRUNCATED - accessibility tree too deep]";
|
||||
|
||||
export function appendRoleSnapshotDepthTruncationMarker(snapshot: string): string {
|
||||
return snapshot
|
||||
? `${snapshot}\n\n${ROLE_SNAPSHOT_DEPTH_TRUNCATION_MARKER}`
|
||||
: ROLE_SNAPSHOT_DEPTH_TRUNCATION_MARKER;
|
||||
}
|
||||
Reference in New Issue
Block a user