diff --git a/src/browser/CdpAgentHints.integration.test.ts b/src/browser/CdpAgentHints.integration.test.ts new file mode 100644 index 0000000000..4bb953188f --- /dev/null +++ b/src/browser/CdpAgentHints.integration.test.ts @@ -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, +); diff --git a/src/browser/CdpCaptureDocuments.baseUrl.test.ts b/src/browser/CdpCaptureDocuments.baseUrl.test.ts index b4b6fb6aef..98e1b15d31 100644 --- a/src/browser/CdpCaptureDocuments.baseUrl.test.ts +++ b/src/browser/CdpCaptureDocuments.baseUrl.test.ts @@ -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", diff --git a/src/browser/CdpCaptureDocuments.ts b/src/browser/CdpCaptureDocuments.ts index 3eca8812f5..bf0f83bd82 100644 --- a/src/browser/CdpCaptureDocuments.ts +++ b/src/browser/CdpCaptureDocuments.ts @@ -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; 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(); 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,