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"