Add BYOB host browser selection

Add host_browser_selection config normalization, Browser Settings UI choices from CLI-advertised inventory, and browser_selection forwarding through connector browser operations. Expose host browser ids, labels, and available_browsers through A0 connector metadata and the browser_runtime API, with regression coverage for selection normalization.

Alessandro committed Jul 4, 2026 at 18:33 UTC 7298a88fda26bf3e1b45dd9570195594defe7af9
10 files changed +158
plugins/_a0_connector/AGENTS.md
+1
@@ -24,6 +24,7 @@
24 - File operation results may arrive as chunked JSON/base64
25 `connector_file_op_result` frames; resolve the pending file operation only
26 after all chunks for the `op_id` are assembled.
27 +- Host browser status metadata may advertise `available_browsers` entries with browser ids, labels, CDP endpoints, status, and enabled state; keep older CLI payloads without those fields compatible.
28
29 ## Work Guidance
30
plugins/_a0_connector/api/v1/browser_runtime.py
+11
@@ -24,6 +24,11 @@ def _normalize_requested_backend(value: object) -> str:
24 return ""
25
26
27 +def _normalize_host_browser_selection(value: object) -> str:
28 + raw = _string(value).lower().replace(" ", "_")
29 + return "".join(ch for ch in raw if ch.isalnum() or ch in {"_", "-", ":", ".", "/"})[:200]
30 +
31 +
32 def _normalize_profile_mode(value: object) -> str:
33 normalized = _string(value).lower().replace("-", "_").replace(" ", "_")
34 if normalized in {"agent", "clean", "clean_agent", "a0", "dedicated"}:
@@ -68,6 +73,10 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler):
73 mimetype="application/json",
74 )
75 settings["runtime_backend"] = runtime_backend
76 + if "host_browser_selection" in input or "browser_selection" in input:
77 + settings["host_browser_selection"] = _normalize_host_browser_selection(
78 + input.get("host_browser_selection", input.get("browser_selection"))
79 + )
80 if "host_browser_profile_mode" in input or "profile_mode" in input:
81 profile_mode = _normalize_profile_mode(
82 input.get("host_browser_profile_mode", input.get("profile_mode"))
@@ -86,11 +95,13 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler):
95
96 runtime_backend = settings.get("runtime_backend") or "container"
97 profile_mode = _normalize_profile_mode(settings.get("host_browser_profile_mode")) or "existing"
98 + browser_selection = _normalize_host_browser_selection(settings.get("host_browser_selection"))
99
100 return {
101 "ok": True,
102 "runtime_backend": runtime_backend,
103 "host_browser_profile_mode": profile_mode,
104 + "host_browser_selection": browser_selection,
105 "label": _runtime_label(runtime_backend),
106 "project_name": project_name,
107 "agent_profile": "",
plugins/_a0_connector/helpers/ws_runtime.py
+39
@@ -80,6 +80,9 @@ class HostBrowserMetadata:
80 profile_label: str
81 profile_path: str
82 cdp_endpoint: str
83 + browser_id: str
84 + browser_label: str
85 + available_browsers: tuple[dict[str, Any], ...]
86 content_helper_sha256: str
87 features: tuple[str, ...]
88 support_reason: str
@@ -416,6 +419,9 @@ def store_sid_host_browser_metadata(sid: str, payload: dict[str, Any]) -> HostBr
419 profile_label=str(payload.get("profile_label", "") or "").strip(),
420 profile_path=str(payload.get("profile_path", "") or "").strip(),
421 cdp_endpoint=str(payload.get("cdp_endpoint", "") or "").strip(),
422 + browser_id=str(payload.get("browser_id", payload.get("browser_selection", "")) or "").strip(),
423 + browser_label=str(payload.get("browser_label", "") or "").strip(),
424 + available_browsers=_normalize_available_host_browsers(payload.get("available_browsers")),
425 content_helper_sha256=str(payload.get("content_helper_sha256", "") or "").strip().lower(),
426 features=features,
427 support_reason=support_reason,
@@ -445,6 +451,32 @@ def _host_browser_can_prepare(
451 )
452
453
454 +def _normalize_available_host_browsers(value: Any) -> tuple[dict[str, Any], ...]:
455 + if not isinstance(value, (list, tuple)):
456 + return ()
457 + browsers: list[dict[str, Any]] = []
458 + for item in value:
459 + if not isinstance(item, dict):
460 + continue
461 + browser_id = str(item.get("id", item.get("browser_id", item.get("selection", ""))) or "").strip()
462 + family = str(item.get("family", item.get("browser_family", "")) or "").strip()
463 + label = str(item.get("label", item.get("name", "")) or "").strip()
464 + cdp_endpoint = str(item.get("cdp_endpoint", "") or "").strip()
465 + status = str(item.get("status", "") or "").strip()
466 + enabled = bool(item.get("enabled", True))
467 + if not any((browser_id, family, label, cdp_endpoint)):
468 + continue
469 + browsers.append({
470 + "id": browser_id or family or cdp_endpoint,
471 + "family": family,
472 + "label": label or family or browser_id or cdp_endpoint,
473 + "cdp_endpoint": cdp_endpoint,
474 + "status": status,
475 + "enabled": enabled,
476 + })
477 + return tuple(browsers)
478 +
479 +
480 def clear_sid_host_browser_metadata(sid: str) -> None:
481 with _state_lock:
482 _sid_host_browser_metadata.pop(sid, None)
@@ -464,6 +496,9 @@ def host_browser_metadata_for_sid(sid: str) -> dict[str, Any] | None:
496 "profile_label": metadata.profile_label,
497 "profile_path": metadata.profile_path,
498 "cdp_endpoint": metadata.cdp_endpoint,
499 + "browser_id": metadata.browser_id,
500 + "browser_label": metadata.browser_label,
501 + "available_browsers": copy.deepcopy(list(metadata.available_browsers)),
502 "content_helper_sha256": metadata.content_helper_sha256,
503 "features": list(metadata.features),
504 "support_reason": metadata.support_reason,
@@ -529,6 +564,10 @@ def all_host_browser_metadata() -> list[dict[str, Any]]:
564 "browser_family": metadata.browser_family,
565 "profile_label": metadata.profile_label,
566 "profile_path": metadata.profile_path,
567 + "cdp_endpoint": metadata.cdp_endpoint,
568 + "browser_id": metadata.browser_id,
569 + "browser_label": metadata.browser_label,
570 + "available_browsers": copy.deepcopy(list(metadata.available_browsers)),
571 "content_helper_sha256": metadata.content_helper_sha256,
572 "features": list(metadata.features),
573 "support_reason": metadata.support_reason,
plugins/_browser/AGENTS.md
+1
@@ -24,6 +24,7 @@
24 - Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback. Do not let the WebUI advertise binary frames unless its Socket.IO client reconstructs attachments as real `Blob`, `ArrayBuffer`, or typed-array values.
25 - Keep WebUI Browser tabs scoped to the active chat context by default; aggregate tabs from other AgentContext runtimes only when the Browser settings tab scope is `shared`.
26 - Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
27 +- For Bring Your Own Browser with an existing host profile, `host_browser_selection` may target automatic CLI selection, a browser family/id, or an explicit CDP endpoint and must be forwarded to the connector runtime as `browser_selection`.
28 - Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
29 - Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
30 - Do not hardcode user-specific browser paths or secrets.
plugins/_browser/default_config.yaml
+5
@@ -33,6 +33,11 @@ host_browser_privacy_policy: "allow"
33 # - agent: use a clean A0-controlled browser profile on the host.
34 host_browser_profile_mode: "existing"
35
36 +# Optional host browser target when using an existing browser.
37 +# Empty means A0 CLI chooses the first supported/active browser.
38 +# Values may be browser family ids (chrome, edge, chromium) or CLI-advertised ids/endpoints.
39 +host_browser_selection: ""
40 +
41 # Optional _model_config preset used by Browser-owned model helpers.
42 # Empty uses the effective Main Model.
43 model_preset: ""
plugins/_browser/helpers/config.py
+13
@@ -16,6 +16,7 @@ MAX_OPEN_TABS_KEY = "max_open_tabs"
16 RUNTIME_BACKEND_KEY = "runtime_backend"
17 HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy"
18 HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode"
19 +HOST_BROWSER_SELECTION_KEY = "host_browser_selection"
20 RUNTIME_BACKENDS = {"container", "host_required"}
21 BROWSER_TAB_SCOPES = {"per_context", "shared"}
22 HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
@@ -58,6 +59,15 @@ def _normalize_model_preset(value: Any) -> str:
59 return str(value or "").strip()
60
61
62 +def _normalize_host_browser_selection(value: Any) -> str:
63 + raw = str(value or "").strip()
64 + if not raw:
65 + return ""
66 + normalized = raw.lower().replace(" ", "_")
67 + # Keep explicit CLI ids/ports/endpoints usable while avoiding control characters.
68 + return "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-", ":", ".", "/"})[:200]
69 +
70 +
71 def _normalize_default_homepage(value: Any) -> str:
72 homepage = str(value or "").strip()
73 return homepage or "about:blank"
@@ -144,6 +154,9 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
154 allowed=HOST_BROWSER_PROFILE_MODES,
155 default="existing",
156 ),
157 + HOST_BROWSER_SELECTION_KEY: _normalize_host_browser_selection(
158 + raw.get(HOST_BROWSER_SELECTION_KEY, raw.get("host_browser_choice", ""))
159 + ),
160 MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
161 }
162
plugins/_browser/helpers/connector_runtime.py
+11
@@ -57,6 +57,11 @@ HOST_BROWSER_PROFILE_MODE_KEY = getattr(
57 "HOST_BROWSER_PROFILE_MODE_KEY",
58 "host_browser_profile_mode",
59 )
60 +HOST_BROWSER_SELECTION_KEY = getattr(
61 + browser_config,
62 + "HOST_BROWSER_SELECTION_KEY",
63 + "host_browser_selection",
64 +)
65 get_browser_config = browser_config.get_browser_config
66 _LOCAL_PROVIDERS = {"ollama", "lm_studio", "llama_cpp", "omlx", "vllm"}
67 _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
@@ -123,6 +128,7 @@ class ConnectorBrowserRuntime:
128 "context_id": self.context_id,
129 "action": action,
130 "profile_mode": self._host_browser_profile_mode(),
131 + "browser_selection": self._host_browser_selection(),
132 }
133
134 if action == "open":
@@ -283,6 +289,7 @@ class ConnectorBrowserRuntime:
289 "context_id": self.context_id,
290 "action": "ensure",
291 "profile_mode": self._host_browser_profile_mode(),
292 + "browser_selection": self._host_browser_selection(),
293 },
294 ),
295 )
@@ -295,6 +302,10 @@ class ConnectorBrowserRuntime:
302 mode = str(config.get(HOST_BROWSER_PROFILE_MODE_KEY) or "existing").strip().lower()
303 return "agent" if mode == "agent" else "existing"
304
305 + def _host_browser_selection(self) -> str:
306 + config = get_browser_config(self.agent)
307 + return str(config.get(HOST_BROWSER_SELECTION_KEY) or "").strip()
308 +
309 def _with_content_helper(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
310 return self._with_browser_helpers(sid, payload)
311
plugins/_browser/webui/browser-config-store.js
+39
@@ -44,6 +44,7 @@ function ensureConfig(config) {
44 HOST_PROFILE_MODES,
45 "existing",
46 );
47 + config.host_browser_selection = normalizeHostBrowserSelection(config.host_browser_selection);
48 config.model_preset = String(config.model_preset || "").trim();
49 delete config.model;
50 return config;
@@ -66,6 +67,10 @@ function normalizeRuntimeBackend(value) {
67 return RUNTIME_BACKENDS.has(normalized) ? normalized : "container";
68 }
69
70 +function normalizeHostBrowserSelection(value) {
71 + return String(value || "").trim().toLowerCase().replace(/\s+/g, "_").slice(0, 200);
72 +}
73 +
74 function normalizeBoolean(value, fallback = true) {
75 if (value === undefined || value === null || value === "") return fallback;
76 if (typeof value === "boolean") return value;
@@ -178,6 +183,40 @@ export const store = createStore("browserConfig", {
183 return "Local Models Only";
184 },
185
186 + hostBrowserOptions() {
187 + const connectors = Array.isArray(this.hostBrowserStatus?.connectors)
188 + ? this.hostBrowserStatus.connectors
189 + : [];
190 + const options = [{ value: "", label: "Automatic (A0 CLI chooses)" }];
191 + const seen = new Set([""]);
192 + for (const connector of connectors) {
193 + const advertised = Array.isArray(connector?.available_browsers)
194 + ? connector.available_browsers
195 + : [];
196 + for (const browser of advertised) {
197 + const value = normalizeHostBrowserSelection(browser?.id || browser?.family || browser?.cdp_endpoint);
198 + if (!value || seen.has(value)) continue;
199 + seen.add(value);
200 + const label = browser?.label || hostBrowserFamilyLabel(browser?.family || value);
201 + const status = browser?.status ? ` - ${hostBrowserStatusLabel(browser.status)}` : "";
202 + options.push({ value, label: `${label}${status}` });
203 + }
204 + const fallbackValue = normalizeHostBrowserSelection(connector?.browser_id || connector?.browser_family);
205 + if (fallbackValue && !seen.has(fallbackValue)) {
206 + seen.add(fallbackValue);
207 + const label = connector?.browser_label || hostBrowserFamilyLabel(connector?.browser_family || fallbackValue);
208 + options.push({ value: fallbackValue, label });
209 + }
210 + }
211 + return options;
212 + },
213 +
214 + setHostBrowserSelection(value) {
215 + const safeConfig = ensureConfig(this.config);
216 + if (!safeConfig) return;
217 + safeConfig.host_browser_selection = normalizeHostBrowserSelection(value);
218 + },
219 +
220 hostBrowserProfileModeLabel() {
221 const value = this.config?.host_browser_profile_mode || "existing";
222 if (value === "agent") return "Clean Agent Profile";
plugins/_browser/webui/config.html
+16
@@ -66,6 +66,22 @@
66 </span>
67 </label>
68
69 + <label
70 + class="browser-config-field"
71 + x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"
72 + >
73 + <span class="browser-config-field-label">Host browser</span>
74 + <select
75 + :value="$store.browserConfig.config.host_browser_selection || ''"
76 + @change="$store.browserConfig.setHostBrowserSelection($event.target.value)"
77 + >
78 + <template x-for="option in $store.browserConfig.hostBrowserOptions()" :key="option.value">
79 + <option :value="option.value" x-text="option.label"></option>
80 + </template>
81 + </select>
82 + <span class="browser-config-field-help">Choose which installed browser/debug endpoint A0 CLI should use when multiple are available.</span>
83 + </label>
84 +
85 <div
86 class="browser-config-warning"
87 x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"
tests/test_browser_agent_regressions.py
+22
@@ -162,6 +162,7 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
162 "runtime_backend": "container",
163 "host_browser_privacy_policy": "allow",
164 "host_browser_profile_mode": "existing",
165 + "host_browser_selection": "",
166 "model_preset": "",
167 }
168
@@ -183,6 +184,27 @@ def test_browser_config_normalizes_host_backend_and_privacy_policy():
184 assert config["runtime_backend"] == "host_required"
185 assert config["host_browser_privacy_policy"] == "warn"
186 assert config["host_browser_profile_mode"] == "agent"
187 +
188 +
189 +def test_browser_config_normalizes_host_browser_selection():
190 + assert normalize_browser_config({})["host_browser_selection"] == ""
191 + assert (
192 + normalize_browser_config({"host_browser_selection": " Edge Dev "})["host_browser_selection"]
193 + == "edge_dev"
194 + )
195 + assert (
196 + normalize_browser_config({"host_browser_choice": "chrome"})["host_browser_selection"]
197 + == "chrome"
198 + )
199 + assert (
200 + normalize_browser_config({"host_browser_selection": "ws://127.0.0.1:9222/devtools"})[
201 + "host_browser_selection"
202 + ]
203 + == "ws://127.0.0.1:9222/devtools"
204 + )
205 + assert normalize_browser_config({"host_browser_selection": "bad\x00value"})[
206 + "host_browser_selection"
207 + ] == "badvalue"
208 assert (
209 normalize_browser_config({"runtime_backend": "host_when_available"})["runtime_backend"]
210 == "host_required"