Use stable Host Browser selections

Prefer stable browser IDs in Browser Settings and translate an exact advertised legacy CDP endpoint before connector dispatch. Preserve unmatched custom endpoints exactly and fail closed, with focused WebUI and runtime regressions.

Alessandro committed Aug 26, 2026 at 15:42 UTC b960af86637477ea6f1eb09f3da6430b6b835493
5 files changed +165 -6
plugins/_browser/AGENTS.md
+1 -1
@@ -39,7 +39,7 @@
39 - Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
40 - For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic CLI selection, a browser family/id, an HTTP CDP discovery address, or a full DevTools WebSocket endpoint and must be forwarded to the connector runtime as `browser_selection`.
41 - Browser Settings must refresh connected A0 CLI host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening.
42 -- Browser Settings keeps the Host browser dropdown focused on automatic selection, advertised debug endpoints, and a validated Custom endpoint field instead of listing every installed local profile. Preserve endpoint path/query case and let A0 CLI resolve discovery addresses on the host.
42 +- Browser Settings keeps the Host browser dropdown focused on automatic selection, stable IDs for advertised debug endpoints, and a validated Custom endpoint field instead of listing every installed local profile. An exact legacy endpoint advertised by a connected A0 CLI migrates to that browser's stable ID in both settings and runtime operations; unmatched custom endpoints remain exact and fail closed. Preserve endpoint path/query case and let A0 CLI resolve discovery addresses on the host.
43 - Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
44 - Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
45 - Do not hardcode user-specific browser paths or secrets.
plugins/_browser/helpers/connector_runtime.py
+30 -3
@@ -105,6 +105,22 @@ _REMOTE_DEBUGGING_ERROR_TOKENS = (
105 )
106
107
108 +def _stable_host_browser_selection(selection: Any, metadata: Any) -> str:
109 + selected = str(selection or "").strip()
110 + if not selected or not isinstance(metadata, dict):
111 + return selected
112 + candidates = list(metadata.get("available_browsers") or [])
113 + candidates.append(metadata)
114 + for candidate in candidates:
115 + if not isinstance(candidate, dict):
116 + continue
117 + endpoint = str(candidate.get("cdp_endpoint") or "").strip()
118 + browser_id = str(candidate.get("id") or candidate.get("browser_id") or "").strip()
119 + if endpoint == selected and browser_id:
120 + return browser_id
121 + return selected
122 +
123 +
124 class ConnectorBrowserRuntime:
125 def __init__(self, context_id: str, agent: Any):
126 self.context_id = str(context_id or "").strip()
@@ -279,6 +295,7 @@ class ConnectorBrowserRuntime:
295 if not sid:
296 statuses = host_browser_metadata_for_context(self.context_id)
297 raise RuntimeError(self._host_browser_unavailable_message(statuses))
298 + payload["browser_selection"] = self._host_browser_selection(sid)
299
300 if self._needs_prepare(sid, payload):
301 await self._send_browser_op(
@@ -290,7 +307,7 @@ class ConnectorBrowserRuntime:
307 "context_id": self.context_id,
308 "action": "ensure",
309 "profile_mode": self._host_browser_profile_mode(),
293 - "browser_selection": self._host_browser_selection(),
310 + "browser_selection": self._host_browser_selection(sid),
311 },
312 ),
313 )
@@ -303,9 +320,19 @@ class ConnectorBrowserRuntime:
320 mode = str(config.get(HOST_BROWSER_PROFILE_MODE_KEY) or "existing").strip().lower()
321 return "agent" if mode == "agent" else "existing"
322
306 - def _host_browser_selection(self) -> str:
323 + def _host_browser_selection(self, sid: str = "") -> str:
324 config = get_browser_config(self.agent)
308 - return str(config.get(HOST_BROWSER_SELECTION_KEY) or "").strip()
325 + selection = str(config.get(HOST_BROWSER_SELECTION_KEY) or "").strip()
326 + if sid:
327 + return _stable_host_browser_selection(
328 + selection,
329 + host_browser_metadata_for_sid(sid),
330 + )
331 + for metadata in host_browser_metadata_for_context(self.context_id):
332 + stable = _stable_host_browser_selection(selection, metadata)
333 + if stable != selection:
334 + return stable
335 + return selection
336
337 def _with_content_helper(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
338 return self._with_browser_helpers(sid, payload)
plugins/_browser/webui/browser-config-store.js
+38 -2
@@ -105,6 +105,24 @@ function isCustomHostBrowserEndpoint(value) {
105 return Boolean(normalizeCustomHostBrowserEndpoint(value));
106 }
107
108 +function stableHostBrowserSelection(value, status) {
109 + const selection = normalizeHostBrowserSelection(value);
110 + if (!selection) return "";
111 + const connectors = Array.isArray(status?.connectors) ? status.connectors : [];
112 + for (const connector of connectors) {
113 + const candidates = [
114 + ...(Array.isArray(connector?.available_browsers) ? connector.available_browsers : []),
115 + connector,
116 + ];
117 + for (const candidate of candidates) {
118 + const endpoint = normalizeCustomHostBrowserEndpoint(candidate?.cdp_endpoint);
119 + const browserId = normalizeHostBrowserSelection(candidate?.id || candidate?.browser_id);
120 + if (endpoint && endpoint === selection && browserId) return browserId;
121 + }
122 + }
123 + return selection;
124 +}
125 +
126 function normalizeBoolean(value, fallback = true) {
127 if (value === undefined || value === null || value === "") return fallback;
128 if (typeof value === "boolean") return value;
@@ -255,14 +273,20 @@ export const store = createStore("browserConfig", {
273 ? connector.available_browsers
274 : [];
275 for (const browser of advertised) {
258 - const value = normalizeCustomHostBrowserEndpoint(browser?.cdp_endpoint || browser?.id);
276 + const endpoint = normalizeCustomHostBrowserEndpoint(browser?.cdp_endpoint);
277 + const value = endpoint
278 + ? normalizeHostBrowserSelection(browser?.id) || endpoint
279 + : "";
280 if (!value || seen.has(value)) continue;
281 seen.add(value);
282 const label = browser?.label || hostBrowserFamilyLabel(browser?.family || value);
283 const status = browser?.status ? ` - ${hostBrowserStatusLabel(browser.status)}` : "";
284 options.push({ value, label: `${label}${status}` });
285 }
265 - const fallbackValue = normalizeCustomHostBrowserEndpoint(connector?.cdp_endpoint || connector?.browser_id);
286 + const fallbackEndpoint = normalizeCustomHostBrowserEndpoint(connector?.cdp_endpoint);
287 + const fallbackValue = fallbackEndpoint
288 + ? normalizeHostBrowserSelection(connector?.browser_id) || fallbackEndpoint
289 + : "";
290 if (fallbackValue && !seen.has(fallbackValue)) {
291 seen.add(fallbackValue);
292 const label = connector?.browser_label || hostBrowserFamilyLabel(connector?.browser_family || fallbackValue);
@@ -338,6 +362,18 @@ export const store = createStore("browserConfig", {
362 try {
363 const response = await callJsonApi(BROWSER_STATUS_API, {});
364 this.hostBrowserStatus = response?.host_browser || { connectors: [] };
365 + const safeConfig = ensureConfig(this.config);
366 + if (safeConfig) {
367 + const stable = stableHostBrowserSelection(
368 + safeConfig.host_browser_selection,
369 + this.hostBrowserStatus,
370 + );
371 + if (stable !== safeConfig.host_browser_selection) {
372 + safeConfig.host_browser_selection = stable;
373 + this.hostBrowserCustomEndpoint = "";
374 + this.hostBrowserCustomMode = false;
375 + }
376 + }
377 } catch (_error) {
378 this.hostBrowserStatus = { connectors: [] };
379 } finally {
tests/test_browser_agent_regressions.py
+51
@@ -2,6 +2,7 @@ import asyncio
2 import concurrent.futures
3 import json
4 import re
5 +import subprocess
6 import sys
7 import threading
8 import zipfile
@@ -277,6 +278,56 @@ def test_browser_config_normalizes_host_browser_selection():
278 )
279
280
281 +def test_browser_config_store_migrates_advertised_endpoint_to_stable_id():
282 + path = PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-config-store.js"
283 + source = path.read_text(encoding="utf-8")
284 + source = re.sub(r"^import .*;\n", "", source, flags=re.M)
285 + source = source.replace("export const store = createStore", "const store = createStore")
286 + endpoint = "ws://localhost:9222/devtools/browser/old-guid"
287 + browser_status = {
288 + "connectors": [
289 + {
290 + "browser_id": "chrome-cdp",
291 + "cdp_endpoint": endpoint,
292 + "available_browsers": [
293 + {
294 + "id": "chrome-cdp",
295 + "family": "chrome-cdp",
296 + "label": "Chrome (allowed)",
297 + "cdp_endpoint": endpoint,
298 + },
299 + {
300 + "id": "chrome:default",
301 + "family": "chrome",
302 + "label": "Chrome profile",
303 + "cdp_endpoint": "",
304 + },
305 + ],
306 + }
307 + ]
308 + }
309 + script = (
310 + "const browserStatus = "
311 + + json.dumps(browser_status)
312 + + ";\n"
313 + + "const createStore = (_name, value) => value;\n"
314 + + "const callJsonApi = async () => ({ host_browser: browserStatus });\n"
315 + + "const showConfirmDialog = async () => false;\n"
316 + + source
317 + + f"\nstore.config = ensureConfig({{ host_browser_selection: {json.dumps(endpoint)} }});\n"
318 + + "await store.loadHostBrowserStatus();\n"
319 + + "if (store.config.host_browser_selection !== 'chrome-cdp') throw new Error('legacy endpoint was not migrated');\n"
320 + + "const values = store.hostBrowserOptions().map((option) => option.value);\n"
321 + + "if (!values.includes('chrome-cdp')) throw new Error('stable browser id is missing');\n"
322 + + f"if (values.includes({json.dumps(endpoint)})) throw new Error('volatile endpoint was advertised');\n"
323 + + "if (values.includes('chrome:default')) throw new Error('local profiles leaked into the dropdown');\n"
324 + + "const custom = 'ws://localhost:9333/devtools/browser/custom';\n"
325 + + "if (stableHostBrowserSelection(custom, browserStatus) !== custom) throw new Error('custom endpoint changed');\n"
326 + )
327 +
328 + subprocess.run(["node", "--input-type=module", "-e", script], check=True, text=True)
329 +
330 +
331 def test_browser_config_normalizes_max_open_tabs():
332 assert normalize_browser_config({"max_open_tabs": "12"})["max_open_tabs"] == 12
333 assert normalize_browser_config({"max_open_tabs": "0"})["max_open_tabs"] == 1
tests/test_host_browser_connector.py
+45
@@ -17,6 +17,7 @@ from plugins._browser.helpers import connector_runtime as connector_runtime_modu
17 from plugins._browser.helpers.connector_runtime import (
18 ConnectorBrowserRuntime,
19 _agent_uses_local_chat_model,
20 + _stable_host_browser_selection,
21 )
22
23
@@ -69,6 +70,50 @@ def test_host_browser_metadata_selection_is_context_scoped():
70 ws_runtime.unregister_sid(sid)
71
72
73 +def test_connector_runtime_uses_stable_id_for_advertised_legacy_endpoint(monkeypatch):
74 + sid = "sid-host-browser-stable"
75 + context_id = "ctx-host-browser-stable"
76 + endpoint = "ws://localhost:9222/devtools/browser/old-guid"
77 + metadata = {
78 + "supported": True,
79 + "enabled": True,
80 + "status": "ready",
81 + "browser_family": "chrome-cdp",
82 + "browser_id": "chrome-cdp",
83 + "cdp_endpoint": endpoint,
84 + "available_browsers": [
85 + {
86 + "id": "chrome-cdp",
87 + "family": "chrome-cdp",
88 + "label": "Chrome (allowed)",
89 + "cdp_endpoint": endpoint,
90 + }
91 + ],
92 + "features": ["ensure", "open"],
93 + }
94 + monkeypatch.setattr(
95 + connector_runtime_module,
96 + "get_browser_config",
97 + lambda agent=None: {
98 + "host_browser_profile_mode": "existing",
99 + "host_browser_selection": endpoint,
100 + },
101 + )
102 + ws_runtime.register_sid(sid)
103 + ws_runtime.subscribe_sid_to_context(sid, context_id)
104 + try:
105 + ws_runtime.store_sid_host_browser_metadata(sid, metadata)
106 + runtime = ConnectorBrowserRuntime(context_id, _agent(context_id))
107 +
108 + assert runtime._payload_for_call("open", "example.com")["browser_selection"] == "chrome-cdp"
109 + assert _stable_host_browser_selection(
110 + "ws://localhost:9333/devtools/browser/custom",
111 + metadata,
112 + ) == "ws://localhost:9333/devtools/browser/custom"
113 + finally:
114 + ws_runtime.unregister_sid(sid)
115 +
116 +
117 def test_host_browser_candidate_selection_allows_disabled_supported_cli():
118 sid = "sid-host-browser-disabled"
119 context_id = "ctx-host-browser-disabled"