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)