Centralize Browser helper contracts

Move URL normalization into Agent Zero-owned Browser helper code and expose the content helper's required API contract from the shared asset. Normalize host-browser open/navigate payloads before they cross into the connector, including nested multi actions, and add regression coverage for helper payload delivery and URL edge cases.

Alessandro committed May 8, 2026 at 16:39 UTC aa7944b95a3c10cf7d2b34ecdfd58959c55ad998
6 files changed +184 -68
plugins/_browser/assets/browser-page-content.js
+36
@@ -2,6 +2,37 @@
2 const GLOBAL_KEY = "__spaceBrowserPageContent__";
3 const DOM_HELPER_KEY = "__spaceBrowserDomHelper__";
4 const VERSION = "11";
5 + const REQUIRED_API_NAMES = Object.freeze([
6 + "annotate",
7 + "boundingBoxFor",
8 + "capture",
9 + "click",
10 + "detail",
11 + "fileInputElementFor",
12 + "fileInputFor",
13 + "pointFor",
14 + "scroll",
15 + "select",
16 + "setChecked",
17 + "submit",
18 + "type",
19 + "typeSubmit"
20 + ]);
21 +
22 + function patchOpenShadowDom() {
23 + const original = Element.prototype.attachShadow;
24 + if (!original || original.__a0BrowserOpenShadowPatch) {
25 + return;
26 + }
27 + const patched = function attachShadow(options) {
28 + return original.call(this, { ...(options || {}), mode: "open" });
29 + };
30 + patched.__a0BrowserOpenShadowPatch = true;
31 + Element.prototype.attachShadow = patched;
32 + }
33 +
34 + patchOpenShadowDom();
35 +
36 const BLOCK_TAGS = new Set([
37 "ADDRESS",
38 "ARTICLE",
@@ -3958,6 +3989,11 @@
3989 setChecked(referenceId, checked) {
3990 return setCheckedReference(referenceId, checked);
3991 },
3992 + ready() {
3993 + const api = globalThis[GLOBAL_KEY];
3994 + return Boolean(api && REQUIRED_API_NAMES.every((name) => typeof api[name] === "function"));
3995 + },
3996 + requiredApis: REQUIRED_API_NAMES.slice(),
3997 version: VERSION
3998 };
3999 })();
plugins/_browser/helpers/connector_runtime.py
+51 -5
@@ -3,6 +3,7 @@ from __future__ import annotations
3 import asyncio
4 import base64
5 import hashlib
6 +import re
7 import uuid
8 from functools import lru_cache
9 from pathlib import Path
@@ -37,6 +38,7 @@ from plugins._browser.helpers.config import (
38 HOST_BROWSER_PRIVACY_POLICY_KEY,
39 get_browser_config,
40 )
41 +from plugins._browser.helpers.url import normalize_url
42
43
44 BROWSER_OP_EVENT = "connector_browser_op"
@@ -48,6 +50,10 @@ BASE64_DECODE_CHARS_PER_CHUNK = 64 * 1024
50 _LOCAL_PROVIDERS = {"ollama", "lm_studio"}
51 _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
52 _SENSITIVE_ACTIONS = {"content", "detail", "evaluate", "screenshot", "screenshot_file"}
53 +_REQUIRED_API_NAMES_RE = re.compile(
54 + r"const\s+REQUIRED_API_NAMES\s*=\s*Object\.freeze\(\[(?P<body>.*?)\]\);",
55 + re.S,
56 +)
57
58
59 class ConnectorBrowserRuntime:
@@ -76,12 +82,12 @@ class ConnectorBrowserRuntime:
82 }
83
84 if action == "open":
79 - payload["url"] = args[0] if args else ""
85 + payload["url"] = self._normalize_open_url(args[0] if args else "")
86 elif action in {"state", "set_active", "back", "forward", "reload"}:
87 payload["browser_id"] = args[0] if args else None
88 elif action == "navigate":
89 payload["browser_id"] = args[0] if args else None
84 - payload["url"] = args[1] if len(args) > 1 else ""
90 + payload["url"] = normalize_url(args[1] if len(args) > 1 else "")
91 elif action == "screenshot_file":
92 payload["action"] = "screenshot"
93 payload["browser_id"] = args[0] if args else None
@@ -145,7 +151,7 @@ class ConnectorBrowserRuntime:
151 payload["ref"] = args[1] if len(args) > 1 else None
152 payload.update(kwargs)
153 elif action == "multi":
148 - payload["calls"] = args[0] if args else []
154 + payload["calls"] = self._normalize_multi_calls(args[0] if args else [])
155 elif action == "close_browser":
156 payload["action"] = "close"
157 payload["browser_id"] = args[0] if args else None
@@ -156,6 +162,31 @@ class ConnectorBrowserRuntime:
162
163 return payload
164
165 + @staticmethod
166 + def _normalize_open_url(value: Any) -> str:
167 + raw = str(value or "").strip()
168 + return normalize_url(raw) if raw else ""
169 +
170 + @classmethod
171 + def _normalize_multi_calls(cls, calls: Any) -> Any:
172 + if not isinstance(calls, list):
173 + return calls
174 + normalized_calls: list[Any] = []
175 + for call in calls:
176 + if not isinstance(call, dict):
177 + normalized_calls.append(call)
178 + continue
179 + normalized = dict(call)
180 + action = str(normalized.get("action") or "").strip().lower().replace("-", "_")
181 + if action == "open":
182 + normalized["url"] = cls._normalize_open_url(normalized.get("url"))
183 + elif action == "navigate":
184 + normalized["url"] = normalize_url(normalized.get("url", ""))
185 + elif action == "multi" or isinstance(normalized.get("calls"), list):
186 + normalized["calls"] = cls._normalize_multi_calls(normalized.get("calls", []))
187 + normalized_calls.append(normalized)
188 + return normalized_calls
189 +
190 async def _dispatch(self, payload: dict[str, Any]) -> Any:
191 self._enforce_privacy(payload)
192 sid = self._select_sid()
@@ -350,7 +381,7 @@ class ConnectorBrowserRuntime:
381
382
383 @lru_cache(maxsize=1)
353 -def _content_helper_payload() -> dict[str, str]:
384 +def _content_helper_payload() -> dict[str, Any]:
385 try:
386 source = CONTENT_HELPER_PATH.read_text(encoding="utf-8")
387 except OSError as exc:
@@ -358,13 +389,28 @@ def _content_helper_payload() -> dict[str, str]:
389 f"Host-browser content helper could not be read from {CONTENT_HELPER_PATH}: {exc}"
390 ) from exc
391 return {
392 + "required_apis": _content_helper_required_apis(source),
393 "source": source,
394 "sha256": hashlib.sha256(source.encode("utf-8")).hexdigest(),
395 }
396
397
398 def _content_helper_sha256() -> str:
367 - return _content_helper_payload()["sha256"]
399 + return str(_content_helper_payload()["sha256"])
400 +
401 +
402 +def _content_helper_required_apis(source: str) -> list[str]:
403 + match = _REQUIRED_API_NAMES_RE.search(source)
404 + if not match:
405 + raise RuntimeError(
406 + f"Host-browser content helper from {CONTENT_HELPER_PATH} does not declare REQUIRED_API_NAMES."
407 + )
408 + names = re.findall(r'"([^"]+)"', match.group("body"))
409 + if not names:
410 + raise RuntimeError(
411 + f"Host-browser content helper from {CONTENT_HELPER_PATH} declares no required API names."
412 + )
413 + return names
414
415
416 def _agent_uses_local_chat_model(agent: Any) -> bool:
plugins/_browser/helpers/runtime.py
+2 -62
@@ -14,7 +14,6 @@ import uuid
14 from dataclasses import dataclass
15 from pathlib import Path
16 from typing import Any
17 -from urllib.parse import urlsplit, urlunsplit
17
18 from helpers import files
19 from helpers.defer import DeferredTask
@@ -26,6 +25,7 @@ from plugins._browser.helpers.config import (
25 get_browser_config,
26 )
27 from plugins._browser.helpers.playwright import configure_playwright_env, ensure_playwright_binary
28 +from plugins._browser.helpers.url import normalize_url
29
30
31 PLUGIN_DIR = Path(__file__).resolve().parents[1]
@@ -275,17 +275,6 @@ CLIPBOARD_BRIDGE_SCRIPT = r"""
275 }
276 """
277
278 -_SPECIAL_SCHEME_RE = re.compile(r"^(?:about|blob|data|file|mailto|tel):", re.I)
279 -_URL_SCHEME_RE = re.compile(r"^[a-z][a-z\d+\-.]*://", re.I)
280 -_LOCAL_HOST_RE = re.compile(
281 - r"^(?:localhost|\[[0-9a-f:.]+\]|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?$",
282 - re.I,
283 -)
284 -_TYPED_HOST_RE = re.compile(
285 - r"^(?:localhost|\[[0-9a-f:.]+\]|(?:\d{1,3}\.){3}\d{1,3}|"
286 - r"(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)+[a-z\d-]{2,63})(?::\d+)?$",
287 - re.I,
288 -)
278 _SAFE_CONTEXT_RE = re.compile(r"[^a-zA-Z0-9_.-]+")
279
280
@@ -301,38 +290,6 @@ def _nudged_viewport(viewport: dict[str, int]) -> dict[str, int]:
290 return {"width": width, "height": height - 1}
291
292
304 -def normalize_url(value: str) -> str:
305 - raw = str(value or "").strip()
306 - if not raw:
307 - raise ValueError("Browser navigation requires a non-empty URL.")
308 -
309 - def with_trailing_path(url: str) -> str:
310 - parts = urlsplit(url)
311 - if parts.scheme in {"http", "https"} and not parts.path:
312 - return urlunsplit((parts.scheme, parts.netloc, "/", parts.query, parts.fragment))
313 - return urlunsplit(parts)
314 -
315 - try:
316 - host = re.split(r"[/?#]", raw, maxsplit=1)[0] or ""
317 - if (
318 - not _URL_SCHEME_RE.match(raw)
319 - and not _SPECIAL_SCHEME_RE.match(raw)
320 - and not raw.startswith(("/", "?", "#", "."))
321 - and not re.search(r"\s", raw)
322 - and _TYPED_HOST_RE.match(host)
323 - ):
324 - protocol = "http://" if _LOCAL_HOST_RE.match(host) else "https://"
325 - return with_trailing_path(protocol + raw)
326 -
327 - parts = urlsplit(raw)
328 - if parts.scheme:
329 - return with_trailing_path(raw)
330 - except Exception:
331 - pass
332 -
333 - return with_trailing_path("https://" + raw)
334 -
335 -
293 def _safe_context_id(context_id: str) -> str:
294 return _SAFE_CONTEXT_RE.sub("_", str(context_id or "default")).strip("._") or "default"
295
@@ -816,7 +773,6 @@ class _BrowserRuntimeCore:
773 self.context.set_default_navigation_timeout(30000)
774 self.context.on("close", self._on_context_closed)
775 self.context.on("page", self._on_new_page_sync)
819 - await self.context.add_init_script(self._shadow_dom_script())
776 await self.context.add_init_script(path=str(CONTENT_HELPER_PATH))
777
778 for page in list(self.context.pages):
@@ -2258,7 +2214,7 @@ class _BrowserRuntimeCore:
2214
2215 async def _ensure_content_helper(self, page: Any) -> None:
2216 has_helper = await page.evaluate(
2261 - "() => Boolean(globalThis.__spaceBrowserPageContent__?.capture && globalThis.__spaceBrowserPageContent__?.annotate && globalThis.__spaceBrowserPageContent__?.boundingBoxFor && globalThis.__spaceBrowserPageContent__?.pointFor && globalThis.__spaceBrowserPageContent__?.select && globalThis.__spaceBrowserPageContent__?.setChecked && globalThis.__spaceBrowserPageContent__?.fileInputFor)"
2217 + "() => Boolean(globalThis.__spaceBrowserPageContent__?.ready?.())"
2218 )
2219 if has_helper:
2220 return
@@ -2266,22 +2222,6 @@ class _BrowserRuntimeCore:
2222 self._content_helper_source = CONTENT_HELPER_PATH.read_text(encoding="utf-8")
2223 await page.evaluate(self._content_helper_source)
2224
2269 - @staticmethod
2270 - def _shadow_dom_script() -> str:
2271 - return """
2272 -(() => {
2273 - const original = Element.prototype.attachShadow;
2274 - if (original && !original.__a0BrowserOpenShadowPatch) {
2275 - const patched = function attachShadow(options) {
2276 - return original.call(this, { ...(options || {}), mode: "open" });
2277 - };
2278 - patched.__a0BrowserOpenShadowPatch = true;
2279 - Element.prototype.attachShadow = patched;
2280 - }
2281 -})();
2282 -"""
2283 -
2284 -
2225 _runtimes: dict[str, BrowserRuntime] = {}
2226 _runtime_lock = threading.RLock()
2227
plugins/_browser/helpers/url.py new
+55
@@ -0,0 +1,55 @@
1 +from __future__ import annotations
2 +
3 +import re
4 +from urllib.parse import urlsplit, urlunsplit
5 +
6 +from helpers.errors import RepairableException
7 +
8 +
9 +_SPECIAL_SCHEME_RE = re.compile(r"^(?:about|blob|data|file|mailto|tel):", re.I)
10 +_URL_SCHEME_RE = re.compile(r"^[a-z][a-z\d+\-.]*://", re.I)
11 +_LOCAL_HOST_RE = re.compile(
12 + r"^(?:localhost|\[[0-9a-f:.]+\]|(?:\d{1,3}\.){3}\d{1,3})(?::\d+)?$",
13 + re.I,
14 +)
15 +_TYPED_HOST_RE = re.compile(
16 + r"^(?:localhost|\[[0-9a-f:.]+\]|(?:\d{1,3}\.){3}\d{1,3}|"
17 + r"(?:[a-z\d](?:[a-z\d-]{0,61}[a-z\d])?\.)+[a-z\d-]{2,63})(?::\d+)?$",
18 + re.I,
19 +)
20 +
21 +
22 +def normalize_url(value: str) -> str:
23 + raw = str(value or "").strip()
24 + if not raw:
25 + raise ValueError("Browser navigation requires a non-empty URL.")
26 + if raw.startswith(("/", "?", "#", ".")):
27 + raise RepairableException(
28 + f"Browser navigation target {raw!r} is relative; provide a full URL with a scheme."
29 + )
30 +
31 + def with_trailing_path(url: str) -> str:
32 + parts = urlsplit(url)
33 + if parts.scheme in {"http", "https"} and not parts.path:
34 + return urlunsplit((parts.scheme, parts.netloc, "/", parts.query, parts.fragment))
35 + return urlunsplit(parts)
36 +
37 + try:
38 + host = re.split(r"[/?#]", raw, maxsplit=1)[0] or ""
39 + if (
40 + not _URL_SCHEME_RE.match(raw)
41 + and not _SPECIAL_SCHEME_RE.match(raw)
42 + and not raw.startswith(("/", "?", "#", "."))
43 + and not re.search(r"\s", raw)
44 + and _TYPED_HOST_RE.match(host)
45 + ):
46 + protocol = "http://" if _LOCAL_HOST_RE.match(host) else "https://"
47 + return with_trailing_path(protocol + raw)
48 +
49 + parts = urlsplit(raw)
50 + if parts.scheme:
51 + return with_trailing_path(raw)
52 + except Exception:
53 + pass
54 +
55 + return with_trailing_path("https://" + raw)
tests/test_browser_agent_regressions.py
+11 -1
@@ -79,6 +79,7 @@ sys.modules.setdefault("plugins._model_config.helpers.model_config", _model_conf
79 def anyio_backend():
80 return "asyncio"
81
82 +from helpers.errors import RepairableException
83 from plugins._browser.helpers.config import (
84 build_browser_launch_config,
85 get_browser_main_model_summary,
@@ -134,6 +135,8 @@ def test_browser_url_normalization_matches_address_bar_hosts():
135 assert normalize_url("novinky.cz") == "https://novinky.cz/"
136 assert normalize_url("https://example.com") == "https://example.com/"
137 assert normalize_url("about:blank") == "about:blank"
138 + with pytest.raises(RepairableException, match="relative"):
139 + normalize_url("/docs")
140
141
142 def test_browser_config_normalizes_extension_paths(tmp_path):
@@ -1201,6 +1204,13 @@ def test_browser_content_helper_keeps_label_wrapped_controls_referenceable():
1204 ).read_text(encoding="utf-8")
1205
1206 assert 'const VERSION = "11"' in helper
1207 + assert "function patchOpenShadowDom" in helper
1208 + assert "Element.prototype.attachShadow = patched" in helper
1209 + assert "const REQUIRED_API_NAMES = Object.freeze([" in helper
1210 + assert "requiredApis: REQUIRED_API_NAMES.slice()" in helper
1211 + assert "ready()" in helper
1212 + for api_name in ("click", "scroll", "submit", "type", "typeSubmit"):
1213 + assert f'"{api_name}"' in helper
1214 assert "function renderControlLabelReferences" in helper
1215 assert "getLabelElementText(labelElement, element)" in helper
1216 assert "return renderControlLabelReferences(node, context);" in helper
@@ -1212,7 +1222,7 @@ def test_browser_runtime_requires_current_content_helper_for_modifier_clicks():
1222 PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
1223 ).read_text(encoding="utf-8")
1224
1215 - assert "__spaceBrowserPageContent__?.boundingBoxFor" in runtime
1225 + assert "__spaceBrowserPageContent__?.ready?.()" in runtime
1226
1227
1228 @pytest.mark.anyio
tests/test_host_browser_connector.py
+29
@@ -196,6 +196,34 @@ def test_host_browser_privacy_blocks_cloud_content(monkeypatch):
196 runtime._enforce_privacy({"action": "content"})
197
198
199 +def test_connector_runtime_normalizes_host_navigation_payloads():
200 + runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
201 +
202 + open_payload = runtime._payload_for_call("open", "localhost:3000")
203 + empty_open_payload = runtime._payload_for_call("open", "")
204 + navigate_payload = runtime._payload_for_call("navigate", 7, "novinky.cz")
205 + multi_payload = runtime._payload_for_call(
206 + "multi",
207 + [
208 + {"action": "open", "url": "example.com"},
209 + {"action": "navigate", "browser_id": 1, "url": "127.0.0.1:8000/path"},
210 + {
211 + "action": "multi",
212 + "calls": [{"action": "open", "url": "nested.example"}],
213 + },
214 + {"action": "content", "browser_id": 1},
215 + ],
216 + )
217 +
218 + assert open_payload["url"] == "http://localhost:3000/"
219 + assert empty_open_payload["url"] == ""
220 + assert navigate_payload["url"] == "https://novinky.cz/"
221 + assert multi_payload["calls"][0]["url"] == "https://example.com/"
222 + assert multi_payload["calls"][1]["url"] == "http://127.0.0.1:8000/path"
223 + assert multi_payload["calls"][2]["calls"][0]["url"] == "https://nested.example/"
224 + assert multi_payload["calls"][3] == {"action": "content", "browser_id": 1}
225 +
226 +
227 def test_host_browser_artifacts_materialize_inside_multi_results(monkeypatch, tmp_path):
228 import plugins._browser.helpers.connector_runtime as connector_runtime_module
229
@@ -326,6 +354,7 @@ def test_connector_runtime_ensures_preparable_host_browser_before_action(monkeyp
354 assert result == {"id": 1, "state": {"runtime": "host"}}
355 assert [payload["action"] for payload in emitted] == ["ensure", "open"]
356 assert "__spaceBrowserPageContent__" in emitted[0]["content_helper"]["source"]
357 + assert "capture" in emitted[0]["content_helper"]["required_apis"]
358 assert emitted[0]["content_helper"]["sha256"]
359 finally:
360 ws_runtime.unregister_sid(sid)