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"