Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
# Changelog

## Unreleased

- Fix strict-CSP screenshot capture by measuring layout and device-pixel ratio without page eval.
- Support the computer-use scroll and activation fallbacks for covered editors without page eval.

## 1.4.12 - 2026-09-10

- Fix strict-CSP failures in computer-use element clicks by running the AX hit-test and remote-object cleanup without page eval.
Expand Down
95 changes: 73 additions & 22 deletions extension/firefox-compat.js
Original file line number Diff line number Diff line change
Expand Up @@ -1160,6 +1160,60 @@
}
return {};
}
case "layoutMetrics": {
const width = window.innerWidth;
const height = window.innerHeight;
const contentWidth = Math.max(document.documentElement?.scrollWidth ?? 0, document.body?.scrollWidth ?? 0, width);
const contentHeight = Math.max(document.documentElement?.scrollHeight ?? 0, document.body?.scrollHeight ?? 0, height);
return {
layoutViewport: { pageX: window.scrollX, pageY: window.scrollY, clientWidth: width, clientHeight: height },
visualViewport: { offsetX: 0, offsetY: 0, pageX: window.scrollX, pageY: window.scrollY, clientWidth: width, clientHeight: height, scale: 1, zoom: 1 },
contentSize: { x: 0, y: 0, width: contentWidth, height: contentHeight },
cssLayoutViewport: { pageX: window.scrollX, pageY: window.scrollY, clientWidth: width, clientHeight: height },
cssVisualViewport: { offsetX: 0, offsetY: 0, pageX: window.scrollX, pageY: window.scrollY, clientWidth: width, clientHeight: height, scale: 1, zoom: 1 },
cssContentSize: { x: 0, y: 0, width: contentWidth, height: contentHeight },
};
}
case "viewportSize":
return { width: window.innerWidth, height: window.innerHeight };
case "devicePixelRatio":
return window.devicePixelRatio;
case "accessibilityScroll":
case "accessibilityActivate": {
try {
const node = nodeFromPayload();
const element = node instanceof Element ? node : node?.parentElement;
if (operation === "accessibilityScroll") {
if (element == null) throw new Error("Cannot scroll a detached accessibility element");
Element.prototype.scrollIntoView.call(element, { behavior: "instant", block: "center", inline: "center" });
} else {
const control = element?.closest('a[href],button,input,select,textarea,label,[role="button"],[role="link"],[role="checkbox"],[role="radio"],[role="switch"]');
const associated = control instanceof HTMLLabelElement ? control.control : control;
const target = associated ?? element?.closest('[contenteditable="true"],[contenteditable=""],[contenteditable="plaintext-only"]') ?? element;
if (!target?.isConnected) throw new Error("Cannot interact with a detached element");
if (!(target instanceof HTMLElement)) throw new Error("Accessibility element does not support programmatic activation");
if (target.matches(":disabled")) throw new Error("Cannot interact with a disabled element");
if ((!associated && target.isContentEditable) || target instanceof HTMLTextAreaElement || (target instanceof HTMLInputElement && !["button", "submit", "reset", "checkbox", "radio", "image", "file", "hidden", "color", "range"].includes(target.type))) {
if ((target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) && target.readOnly) throw new Error("Cannot focus a read-only element");
target.focus({ preventScroll: true });
const root = target.getRootNode();
const focused = root instanceof ShadowRoot ? root.activeElement : target.ownerDocument.activeElement;
if (focused !== target && !focused?.contains(target)) throw new Error("Could not focus the target element");
} else {
target.click();
}
}
return { result: { type: "undefined" } };
} catch (error) {
return {
result: { type: "undefined" },
exceptionDetails: {
text: error?.message ?? String(error), lineNumber: 0, columnNumber: 0,
exception: { type: "object", subtype: "error", className: error?.name ?? "Error", description: String(error) },
},
};
}
}
case "boxModel": {
const node = nodeFromPayload();
if (!(node instanceof Element)) throw new Error("DOM element not found");
Expand Down Expand Up @@ -2107,6 +2161,9 @@
const returnByValue = params.returnByValue === true;
const awaitPromise = params.awaitPromise === true;
const objectGroup = typeof params.objectGroup === "string" ? params.objectGroup : null;
if (expression === "window.devicePixelRatio") {
return { result: byValueRemote(await executeCspSafePageOperation(tabId, "devicePixelRatio", {}, { frameId })) };
}
const browserUseBindingName = browserUseBindingNameFromExpression(expression);
if (browserUseBindingName != null) {
const installed = bindingNamesByTab.get(tabId)?.has(browserUseBindingName) === true;
Expand Down Expand Up @@ -2239,6 +2296,20 @@
&& declaration.includes("viewportSize:")) {
return executeCspSafePageOperation(tabId, "accessibilityHitTest", { objectId: params.objectId }, { frameId });
}
if (params.objectId && params.returnByValue === true
&& declaration.startsWith("function (argument) { const __name = (target) => target; return (")
&& declaration.endsWith(").call(this, argument); }")) {
let operation;
if (declaration.includes("Cannot scroll a detached accessibility element")
&& declaration.includes("Element.prototype.scrollIntoView.call")) {
operation = "accessibilityScroll";
} else if (declaration.includes("Accessibility element does not support programmatic activation")
&& declaration.includes("Cannot focus a read-only element")
&& declaration.includes("Could not focus the target element")) {
operation = "accessibilityActivate";
}
if (operation) return executeCspSafePageOperation(tabId, operation, { objectId: params.objectId }, { frameId });
}
const argumentsJson = JSON.stringify(params.arguments ?? []);
const returnByValue = params.returnByValue === true;
const awaitPromise = params.awaitPromise === true;
Expand Down Expand Up @@ -2598,31 +2669,11 @@
}

async function getLayoutMetrics(tabId, frameId = 0) {
return executeUserScript(
tabId,
operationScript(`
const width = window.innerWidth;
const height = window.innerHeight;
const contentWidth = Math.max(document.documentElement?.scrollWidth ?? 0, document.body?.scrollWidth ?? 0, width);
const contentHeight = Math.max(document.documentElement?.scrollHeight ?? 0, document.body?.scrollHeight ?? 0, height);
return {
layoutViewport: { pageX: window.scrollX, pageY: window.scrollY, clientWidth: width, clientHeight: height },
visualViewport: { offsetX: 0, offsetY: 0, pageX: window.scrollX, pageY: window.scrollY, clientWidth: width, clientHeight: height, scale: 1, zoom: 1 },
contentSize: { x: 0, y: 0, width: contentWidth, height: contentHeight },
cssLayoutViewport: { pageX: window.scrollX, pageY: window.scrollY, clientWidth: width, clientHeight: height },
cssVisualViewport: { offsetX: 0, offsetY: 0, pageX: window.scrollX, pageY: window.scrollY, clientWidth: width, clientHeight: height, scale: 1, zoom: 1 },
cssContentSize: { x: 0, y: 0, width: contentWidth, height: contentHeight },
};
`),
{ frameId },
);
return executeCspSafePageOperation(tabId, "layoutMetrics", {}, { frameId });
}

async function readViewportSize(tabId) {
return executeUserScript(
tabId,
operationScript(`return { width: window.innerWidth, height: window.innerHeight };`),
);
return executeCspSafePageOperation(tabId, "viewportSize", {});
}

async function resizeWindowForViewport(windowId, tabId, viewport, state) {
Expand Down
8 changes: 6 additions & 2 deletions tests/fixtures/README.md
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
# Computer-use protocol fixtures

`cua-ax-functions.json` records the node-bound hit-test callback sent by the
`cua-ax-functions.json` records the node-bound hit-test, scrolling, and activation callbacks sent by the
installed Browser Use runtime (26.903.71938) during `tab.click(elementIndex)`.
It is passed unchanged to Runtime.callFunctionOn in the real Firefox test.
They are passed unchanged to Runtime.callFunctionOn in the real Firefox test.
The static equivalent in firefox-compat.js preserves its hit-test semantics
without compiling JavaScript inside the website's CSP realm.

Expand All @@ -11,3 +11,7 @@ and DOM protocol, runs this callback, dispatches mouse events, inserts text, and
checks the input value and foreground-tab preservation. To demonstrate the
regression against a prior adapter, set FIREFOX_COMPAT_SOURCE to that adapter's
path when running npm run test:live.

The live test also covers CSP-safe layout metrics, device-pixel ratio, PNG capture,
and multiline replacement in a covered textarea below the viewport. The fixture
loads its CSS from the same origin so its overlay is enforced under strict CSP.
4 changes: 3 additions & 1 deletion tests/fixtures/cua-ax-functions.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{
"hitTest": "function (argument) { const __name = (target) => target; return (function nX(){let e=this instanceof Element?this:this.parentElement;if(e==null)throw new Error(\"Cannot hit-test a detached accessibility element\");let t=e.getClientRects()[0]??e.getBoundingClientRect();if(this instanceof Text){let A=document.createRange();A.selectNode(this),t=A.getClientRects()[0]??A.getBoundingClientRect()}let r={x:t.left+t.width/2,y:t.top+t.height/2},n=e.ownerDocument.defaultView;if(n==null)throw new Error(\"Cannot hit-test an accessibility element without a viewport\");let o=n.visualViewport,i=o?.offsetLeft??0,s=o?.offsetTop??0,a=Math.max(t.left,i),l=Math.min(t.right,i+(o?.width??n.innerWidth)),c=Math.max(t.top,s),p=Math.min(t.bottom,s+(o?.height??n.innerHeight)),m=l>a&&p>c?{x:(a+l)/2,y:(c+p)/2}:r,h=e instanceof HTMLInputElement||e instanceof HTMLButtonElement||e instanceof HTMLSelectElement||e instanceof HTMLTextAreaElement?e:void 0,y=[e,...Array.from(h?.labels??[])],w=[\"button\",\"a[href]\",\"input\",\"select\",\"textarea\",'[role=\"button\"]','[role=\"checkbox\"]','[role=\"combobox\"]','[role=\"link\"]','[role=\"listbox\"]','[role=\"menuitem\"]','[role=\"option\"]','[role=\"radio\"]','[role=\"slider\"]','[role=\"spinbutton\"]','[role=\"switch\"]','[role=\"tab\"]','[role=\"textbox\"]','[tabindex]:not([tabindex=\"-1\"])'].join(\",\"),x=(A,L=m.x,$=m.y)=>{let U=A.getRootNode(),X=U instanceof ShadowRoot?U.elementFromPoint(L,$):A.ownerDocument.elementFromPoint(L,$);for(;X?.shadowRoot!=null;){let oe=X.shadowRoot.elementFromPoint(L,$);if(oe==null||oe===X)break;X=oe}return X},S=(A,L,$=!1)=>{let U=!1;for(let X=L;X!=null;){if(X===A)return!$||U;let oe=X instanceof Element||X instanceof Text?X.assignedSlot:null;if(oe!=null)U=!0,X=oe;else if(X.parentNode!=null)X=X.parentNode;else{let me=X.getRootNode(),se=me instanceof ShadowRoot?me.host:null;U||=se!=null,X=se}}return!1},D=(A,L=m.x,$=m.y)=>{let U=x(A,L,$);if(U==null)return!1;let X=U.closest(w),oe=(A instanceof HTMLLabelElement?A.control:null)??A.closest(w);if(oe!=null&&X!=null&&X!==oe&&!S(X,oe))return!1;if(S(A,U)||S(U,A,!0))return!0;let me=A.closest(\"label\")?.control;return me!=null&&(S(U,me)||S(me,U)||A===e&&U.parentElement===me.parentElement&&(X==null||S(X,me)))||oe!=null&&oe===X?!0:getComputedStyle(A).pointerEvents===\"none\"&&S(U,A)},T=y.some(A=>D(A));return{fallbackPoint:(T?null:y.slice(1).flatMap(A=>Array.from(A.getClientRects()).map(L=>({candidate:A,point:{x:L.left+L.width/2,y:L.top+L.height/2},size:{height:L.height,width:L.width}}))).find(({candidate:A,point:L,size:$})=>$.width>1&&$.height>1&&D(A,L.x,L.y))?.point)??null,hitTag:x(e)?.tagName??null,hitsTarget:T,point:m,viewportSize:{height:n.innerHeight,width:n.innerWidth}}}).call(this, argument); }"
"hitTest": "function (argument) { const __name = (target) => target; return (function nX(){let e=this instanceof Element?this:this.parentElement;if(e==null)throw new Error(\"Cannot hit-test a detached accessibility element\");let t=e.getClientRects()[0]??e.getBoundingClientRect();if(this instanceof Text){let A=document.createRange();A.selectNode(this),t=A.getClientRects()[0]??A.getBoundingClientRect()}let r={x:t.left+t.width/2,y:t.top+t.height/2},n=e.ownerDocument.defaultView;if(n==null)throw new Error(\"Cannot hit-test an accessibility element without a viewport\");let o=n.visualViewport,i=o?.offsetLeft??0,s=o?.offsetTop??0,a=Math.max(t.left,i),l=Math.min(t.right,i+(o?.width??n.innerWidth)),c=Math.max(t.top,s),p=Math.min(t.bottom,s+(o?.height??n.innerHeight)),m=l>a&&p>c?{x:(a+l)/2,y:(c+p)/2}:r,h=e instanceof HTMLInputElement||e instanceof HTMLButtonElement||e instanceof HTMLSelectElement||e instanceof HTMLTextAreaElement?e:void 0,y=[e,...Array.from(h?.labels??[])],w=[\"button\",\"a[href]\",\"input\",\"select\",\"textarea\",'[role=\"button\"]','[role=\"checkbox\"]','[role=\"combobox\"]','[role=\"link\"]','[role=\"listbox\"]','[role=\"menuitem\"]','[role=\"option\"]','[role=\"radio\"]','[role=\"slider\"]','[role=\"spinbutton\"]','[role=\"switch\"]','[role=\"tab\"]','[role=\"textbox\"]','[tabindex]:not([tabindex=\"-1\"])'].join(\",\"),x=(A,L=m.x,$=m.y)=>{let U=A.getRootNode(),X=U instanceof ShadowRoot?U.elementFromPoint(L,$):A.ownerDocument.elementFromPoint(L,$);for(;X?.shadowRoot!=null;){let oe=X.shadowRoot.elementFromPoint(L,$);if(oe==null||oe===X)break;X=oe}return X},S=(A,L,$=!1)=>{let U=!1;for(let X=L;X!=null;){if(X===A)return!$||U;let oe=X instanceof Element||X instanceof Text?X.assignedSlot:null;if(oe!=null)U=!0,X=oe;else if(X.parentNode!=null)X=X.parentNode;else{let me=X.getRootNode(),se=me instanceof ShadowRoot?me.host:null;U||=se!=null,X=se}}return!1},D=(A,L=m.x,$=m.y)=>{let U=x(A,L,$);if(U==null)return!1;let X=U.closest(w),oe=(A instanceof HTMLLabelElement?A.control:null)??A.closest(w);if(oe!=null&&X!=null&&X!==oe&&!S(X,oe))return!1;if(S(A,U)||S(U,A,!0))return!0;let me=A.closest(\"label\")?.control;return me!=null&&(S(U,me)||S(me,U)||A===e&&U.parentElement===me.parentElement&&(X==null||S(X,me)))||oe!=null&&oe===X?!0:getComputedStyle(A).pointerEvents===\"none\"&&S(U,A)},T=y.some(A=>D(A));return{fallbackPoint:(T?null:y.slice(1).flatMap(A=>Array.from(A.getClientRects()).map(L=>({candidate:A,point:{x:L.left+L.width/2,y:L.top+L.height/2},size:{height:L.height,width:L.width}}))).find(({candidate:A,point:L,size:$})=>$.width>1&&$.height>1&&D(A,L.x,L.y))?.point)??null,hitTag:x(e)?.tagName??null,hitsTarget:T,point:m,viewportSize:{height:n.innerHeight,width:n.innerWidth}}}).call(this, argument); }",
"scroll": "function (argument) { const __name = (target) => target; return (function eX(){let e=this instanceof Element?this:this.parentElement;if(e==null)throw new Error(\"Cannot scroll a detached accessibility element\");Element.prototype.scrollIntoView.call(e,{behavior:\"instant\",block:\"center\",inline:\"center\"})}).call(this, argument); }",
"activate": "function (argument) { const __name = (target) => target; return (function tX(){let e=this instanceof Element?this:this.parentElement,t=e?.closest('a[href],button,input,select,textarea,label,[role=\"button\"],[role=\"link\"],[role=\"checkbox\"],[role=\"radio\"],[role=\"switch\"]'),r=t instanceof HTMLLabelElement?t.control:t,n=r??e?.closest('[contenteditable=\"true\"],[contenteditable=\"\"],[contenteditable=\"plaintext-only\"]')??e;if(!n?.isConnected)throw new Error(\"Cannot interact with a detached element\");if(!(n instanceof HTMLElement))throw new Error(\"Accessibility element does not support programmatic activation\");if(n.matches(\":disabled\"))throw new Error(\"Cannot interact with a disabled element\");if(!r&&n.isContentEditable||n instanceof HTMLTextAreaElement||n instanceof HTMLInputElement&&![\"button\",\"submit\",\"reset\",\"checkbox\",\"radio\",\"image\",\"file\",\"hidden\",\"color\",\"range\"].includes(n.type)){if((n instanceof HTMLInputElement||n instanceof HTMLTextAreaElement)&&n.readOnly)throw new Error(\"Cannot focus a read-only element\");n.focus({preventScroll:!0});let i=n.getRootNode(),s=i instanceof ShadowRoot?i.activeElement:n.ownerDocument.activeElement;if(s!==n&&!s?.contains(n))throw new Error(\"Could not focus the target element\")}else n.click()}).call(this, argument); }"
}
Loading
Loading