fix(browser): preserve prefixed XHTML link hints (#7819)
This commit is contained in:
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user