fix(browser): preserve prefixed XHTML link hints (#7819)

This commit is contained in:
morluto authored and GitHub committed 2026-10-29 01:10:36 +08:00
1 parent dfa0689890
commit 7dbebb4e7d
3 files changed
+180 -14

No files matched your search

@@ -0,0 +1,90 @@
import { expect, it } from "vitest";
import { startFakeCdpBrowser } from "../../tests/fixtures/fakeCdpBrowser.js";
import { createBrowserObservationProvider } from "../composition/browserObservation.js";
import { inspectWebPage } from "../application/BrowserObservationService.js";
import { inspectWebPageInputSchema } from "../domain/browserObservation.js";
import { webPageInspectionSchema } from "../domain/browserObservationSchemas.js";
it.each(["", "h:"])(
"retains agent hints from XHTML links with prefix %j",
async (prefix) => {
const declarations = ["mcp", "model-context", "ai-plugin", "service-desc"];
const browser = await startFakeCdpBrowser({
commandResult: (command, origin) =>
command.method === "DOMSnapshot.captureSnapshot"
? {
strings: [
"#document",
`${prefix}html`,
`${prefix}head`,
`${prefix}link`,
"",
prefix ? "xmlns:h" : "xmlns",
"http://www.w3.org/1999/xhtml",
"rel",
declarations.join(" "),
"href",
"/agent",
`${origin}/root/page.xhtml`,
],
documents: [
{
documentURL: 11,
nodes: {
nodeType: [9, 1, 1, 1],
nodeName: [0, 1, 2, 3],
nodeValue: [4, 4, 4, 4],
parentIndex: [-1, 0, 1, 2],
attributes: [[], [5, 6], [], [7, 8, 9, 10]],
},
},
],
}
: undefined,
});
const provider = createBrowserObservationProvider();
try {
const result = await inspectWebPage(
provider,
inspectWebPageInputSchema.parse({
cdp_endpoint: browser.endpoint,
target_id: "allowed-page",
observation_ms: 1,
}),
);
if (!result.ok) throw result.error;
const value = webPageInspectionSchema.parse(
result.value.normalized_result,
);
expect(value.metadata.dom_urls).toEqual([
{
node_index: 3,
attribute: "href",
url: `${browser.allowedOrigin}/agent`,
destination_scope: "approved",
},
]);
expect(
value.metadata.agent_hints.filter(
(hint) => hint.mechanism === "dom_link_rel",
),
).toEqual(
declarations.map((declaration) => ({
mechanism: "dom_link_rel",
declaration,
url: `${browser.allowedOrigin}/agent`,
trust: "page-declared-untrusted",
})),
);
expect(
browser.commands.some(
(command) => command.method === "DOMSnapshot.captureSnapshot",
),
).toBe(true);
} finally {
await provider.close?.();
await browser.close();
}
},
30_000,
);
@@ -231,6 +231,15 @@ describe("DOM metadata document base URLs", () => {
});
describe("prefixed XHTML form destinations", () => {
it("resolves a prefixed base href against the document, not itself", () => {
const value = prefixedXhtmlSnapshot();
value.strings[1] = `${origin}/root/assets/`;
value.strings[13] = "assets/";
const step = captureDom(value, new Set([origin]), input).next();
if (!step.done) throw new Error("Unexpected SVG identity query");
expect(step.value.urls[0]?.url).toBe(`${origin}/root/assets/`);
});
it("resolves empty actions to the document URL", () => {
const result = captureDom(
prefixedXhtmlSnapshot(),
@@ -940,6 +949,65 @@ const approvedHref = (
destination_scope: "approved",
});
describe("XHTML link agent hints", () => {
it.each([
["html", "link", "xmlns"],
["h:html", "h:link", "xmlns:h"],
])("recognizes equivalent %s namespaces", (root, link, namespace) => {
const result = captureSvg(
svgDocument([
{ name: root, parent: 0, attributes: [[namespace, XHTML_NAMESPACE]] },
{
name: link,
parent: 1,
attributes: [
["href", "/agent"],
["rel", "mcp model-context ai-plugin service-desc"],
],
},
]),
);
expect(result.agentHints).toEqual(
["mcp", "model-context", "ai-plugin", "service-desc"].map(
(declaration) => ({
mechanism: "dom_link_rel",
declaration,
url: `${origin}/agent`,
trust: "page-declared-untrusted",
}),
),
);
});
it("does not treat foreign or differently cased XML elements as XHTML links", () => {
const result = captureSvg(
svgDocument([
xhtmlRoot([
["xmlns", XHTML_NAMESPACE],
["xmlns:f", "urn:foreign"],
]),
{
name: "f:link",
parent: 1,
attributes: [
["rel", "mcp"],
["href", "/agent"],
],
},
{
name: "LINK",
parent: 1,
attributes: [
["rel", "mcp"],
["href", "/agent"],
],
},
]),
);
expect(result.agentHints).toEqual([]);
});
});
describe("XML SVG roots and namespace scope restoration", () => {
it.each(["svg", "document"])(
"resolves namespaces under an unprefixed XML %s root",
+22 -14
View File
@@ -354,6 +354,7 @@ export function* captureDom(
const nodeIndex = baseIndex + index;
const attributeNames = [...new Set(attributes.map(([name]) => name))];
const domElement = domXmlElement(ancestry, index);
const htmlTagName = domHtmlTagName(nodeName, domElement);
if (domElement.xmlDocument === undefined)
completeness?.exclude("metadata", "invalid_protocol_value");
let svgQuery: DomSvgNodeQuery | null | undefined;
@@ -380,10 +381,10 @@ export function* captureDom(
options: {
attributes,
// The BASE element itself resolves against the document fallback URL.
baseUrl: nodeName.toLowerCase() === "base" ? documentUrl : baseUrl,
baseUrl: htmlTagName === "base" ? documentUrl : baseUrl,
documentUrl,
nodeIndex,
nodeName,
htmlTagName,
allowedOrigins,
...domElement,
},
@@ -686,7 +687,7 @@ interface DomMetadataOptions {
readonly baseUrl: string;
readonly documentUrl: string;
readonly nodeIndex: number;
readonly nodeName: string;
readonly htmlTagName: string;
readonly allowedOrigins: ReadonlySet<string>;
readonly svgElement: boolean;
readonly xmlDocument: boolean | undefined;
@@ -695,6 +696,21 @@ interface DomMetadataOptions {
readonly xlinkHref: string | undefined;
}
/** Interpret the element once using its resolved namespace, not its raw prefix. */
const domHtmlTagName = (
nodeName: string,
element: Pick<
DomMetadataOptions,
"xmlDocument" | "elementNamespace" | "localName"
>,
): string =>
element.xmlDocument === false
? nodeName.toLowerCase()
: element.xmlDocument === true &&
element.elementNamespace === XHTML_NAMESPACE
? (element.localName ?? "")
: "";
// Chrome reads an SVG XLink href only when href itself is absent, and an
// empty href still wins. HTML keeps the literal attribute name `xlink:href`.
// XML uses any prefix bound to the XLink namespace. Both are stored as href.
@@ -720,27 +736,19 @@ const domMetadata = (
baseUrl,
documentUrl,
nodeIndex,
nodeName,
htmlTagName,
allowedOrigins,
xmlDocument,
elementNamespace,
localName,
} = options;
const pairs = new Map<string, string>();
for (const [name, value] of attributes) {
// HTML keeps the first declaration; SVG/XML spelling stays case-sensitive.
if (!pairs.has(name)) pairs.set(name, value);
}
const tagName = htmlTagName;
const urls: WebPageInspection["metadata"]["dom_urls"] = [];
for (const attribute of domUrlAttributes) {
const value = hrefAttributeValue(pairs, attribute, options);
if (value === undefined) continue;
const tagName =
xmlDocument === false
? nodeName.toLowerCase()
: xmlDocument === true && elementNamespace === XHTML_NAMESPACE
? (localName ?? "")
: "";
// Chrome strips HTML whitespace for FORM.action, while submit controls
// use the document URL only for an exactly empty formaction attribute.
const usesDocumentUrl =
@@ -769,7 +777,7 @@ const domMetadata = (
.filter((value) => agentRelValues.includes(value));
const href = urls.find(({ attribute }) => attribute === "href")?.url ?? null;
const agentHints =
nodeName.toLowerCase() === "link" && rel.length > 0
tagName === "link" && rel.length > 0
? rel.map((declaration) => ({
mechanism: "dom_link_rel" as const,
declaration,