Refine host browser routing and settings copy

Store and surface host-browser preparation and CDP endpoint metadata from A0 CLI. Let Browser runtime prepare candidate CLIs before the first action, and keep host-required errors more actionable. Simplify Host Browser settings language and document the Chrome remote-debugging consent flow.

Alessandro committed May 8, 2026 at 06:37 UTC d47207dfd7dcbf12d83dba81f8a7f9cd418194e2
7 files changed +155 -37
docs/guides/a0-cli-connector.md
+17 -10
@@ -51,7 +51,12 @@ to pair it with local-model enforcement for host-browser content.
51
52 1. Keep A0 CLI connected to the Agent Zero chat.
53
54 -2. Optionally list or select a Chrome-family profile:
54 +2. If you want Agent Zero to use an already-open personal Chrome window, open
55 + `chrome://inspect/#remote-debugging` and click **Allow** for that browser
56 + instance. A0 CLI detects Chrome's local `DevToolsActivePort` file; status and
57 + profile checks do not connect to Chrome.
58 +
59 +3. Optionally list or select a Chrome-family profile:
60
61 ```bash
62 /browser profile
@@ -60,12 +65,12 @@ to pair it with local-model enforcement for host-browser content.
65 ```
66
67 Chrome 136+ blocks Playwright remote debugging against the default personal
63 -Chrome data directory. In that case, choose the A0-controlled local profile
64 -(`chrome-a0 Default` for Google Chrome). Cookies and site data remain in that
65 -separate browser profile on the host, and the user may need to sign in there
66 -once.
68 +Chrome data directory. If Chrome's own Remote debugging consent path is not
69 +available, choose the A0-controlled local profile (`chrome-a0 Default` for
70 +Google Chrome). Cookies and site data remain in that separate browser profile on
71 +the host, and the user may need to sign in there once.
72
68 -3. In Agent Zero WebUI, open Browser plugin settings and choose one of:
73 +4. In Agent Zero WebUI, open Browser plugin settings and choose one of:
74
75 - `container`: always use the Docker/server Playwright browser.
76 - `host_when_available`: use the A0 CLI host browser when the subscribed CLI can provide it, otherwise fall back to container.
@@ -81,13 +86,15 @@ still useful for diagnostics and manual override:
86 /browser relaunch
87 ```
88
84 -4. If the selected Chrome profile is already open normally, A0 CLI reports
89 +5. If the selected Chrome profile is already open normally, A0 CLI reports
90 `relaunch_required`. Close that browser and retry the agent request or run
91 `/browser relaunch` manually.
92
88 -The MVP uses Python Playwright against installed system Chrome, Chromium, or
89 -Edge. It does not require a Chrome extension, and it does not copy browser
90 -credentials, cookies, or profile data out of the browser profile.
93 +The local-profile launch path uses Python Playwright against installed system
94 +Chrome, Chromium, or Edge. The user-authorized Chrome remote debugging path uses
95 +A0 CLI's built-in DevTools Protocol helper instead, so users do not need to
96 +install Chrome DevTools MCP. A0 does not copy browser credentials, cookies, or
97 +profile data out of the browser profile.
98
99 Host-browser page content and screenshots are controlled by the Browser
100 plugin's project-level policy:
plugins/_a0_connector/helpers/ws_runtime.py
+28 -2
@@ -62,11 +62,13 @@ class ComputerUseMetadata:
62 @dataclass(frozen=True)
63 class HostBrowserMetadata:
64 supported: bool
65 + can_prepare: bool
66 enabled: bool
67 status: str
68 browser_family: str
69 profile_label: str
70 profile_path: str
71 + cdp_endpoint: str
72 features: tuple[str, ...]
73 support_reason: str
74 updated_at: float
@@ -359,15 +361,18 @@ def store_sid_host_browser_metadata(sid: str, payload: dict[str, Any]) -> HostBr
361 features = tuple(str(item).strip() for item in features_value if str(item).strip())
362 else:
363 features = ()
364 + support_reason = str(payload.get("support_reason", "") or "").strip()
365 metadata = HostBrowserMetadata(
366 supported=bool(payload.get("supported")),
367 + can_prepare=_host_browser_can_prepare(payload, features=features, support_reason=support_reason),
368 enabled=bool(payload.get("supported")) and bool(payload.get("enabled")),
369 status=str(payload.get("status", "") or "").strip(),
370 browser_family=str(payload.get("browser_family", "") or "").strip(),
371 profile_label=str(payload.get("profile_label", "") or "").strip(),
372 profile_path=str(payload.get("profile_path", "") or "").strip(),
373 + cdp_endpoint=str(payload.get("cdp_endpoint", "") or "").strip(),
374 features=features,
370 - support_reason=str(payload.get("support_reason", "") or "").strip(),
375 + support_reason=support_reason,
376 updated_at=time.time(),
377 )
378 with _state_lock:
@@ -375,6 +380,25 @@ def store_sid_host_browser_metadata(sid: str, payload: dict[str, Any]) -> HostBr
380 return metadata
381
382
383 +def _host_browser_can_prepare(
384 + payload: dict[str, Any],
385 + *,
386 + features: tuple[str, ...],
387 + support_reason: str,
388 +) -> bool:
389 + if "can_prepare" in payload:
390 + return bool(payload.get("can_prepare"))
391 + if "ensure" not in features:
392 + return False
393 + reason = support_reason.lower()
394 + return (
395 + "python playwright" in reason
396 + or "a0-controlled local profile" in reason
397 + or "chrome-a0" in reason
398 + or "remote debugging" in reason
399 + )
400 +
401 +
402 def clear_sid_host_browser_metadata(sid: str) -> None:
403 with _state_lock:
404 _sid_host_browser_metadata.pop(sid, None)
@@ -387,11 +411,13 @@ def host_browser_metadata_for_sid(sid: str) -> dict[str, Any] | None:
411 return None
412 return {
413 "supported": metadata.supported,
414 + "can_prepare": metadata.can_prepare,
415 "enabled": metadata.enabled,
416 "status": metadata.status,
417 "browser_family": metadata.browser_family,
418 "profile_label": metadata.profile_label,
419 "profile_path": metadata.profile_path,
420 + "cdp_endpoint": metadata.cdp_endpoint,
421 "features": list(metadata.features),
422 "support_reason": metadata.support_reason,
423 "updated_at": metadata.updated_at,
@@ -421,7 +447,7 @@ def select_host_browser_candidate_sid(context_id: str) -> str | None:
447 fallback: str | None = None
448 for sid in subscribers:
449 metadata = _sid_host_browser_metadata.get(sid)
424 - if not metadata or not metadata.supported:
450 + if not metadata or not (metadata.supported or metadata.can_prepare):
451 continue
452 if metadata.enabled and metadata.status in {"ready", "active"}:
453 return sid
plugins/_browser/helpers/connector_runtime.py
+2 -1
@@ -328,7 +328,8 @@ class ConnectorBrowserRuntime:
328 for status in statuses:
329 parts.append(
330 f"sid={status.get('sid')} status={status.get('status')} "
331 - f"supported={status.get('supported')} enabled={status.get('enabled')} "
331 + f"supported={status.get('supported')} can_prepare={status.get('can_prepare')} "
332 + f"enabled={status.get('enabled')} "
333 f"reason={status.get('support_reason') or 'none'}"
334 )
335 return "; ".join(parts)
plugins/_browser/helpers/selector.py
+2 -1
@@ -59,7 +59,8 @@ def _host_browser_status_detail(context_id: str) -> str:
59 for status in statuses:
60 parts.append(
61 f"sid={status.get('sid')} supported={status.get('supported')} "
62 - f"enabled={status.get('enabled')} status={status.get('status') or 'unknown'} "
62 + f"can_prepare={status.get('can_prepare')} enabled={status.get('enabled')} "
63 + f"status={status.get('status') or 'unknown'} "
64 f"reason={status.get('support_reason') or 'none'}"
65 )
66 return "; ".join(parts)
plugins/_browser/webui/browser-config-store.js
+37 -10
@@ -52,6 +52,32 @@ function normalizeBoolean(value, fallback = true) {
52 return fallback;
53 }
54
55 +function hostBrowserFamilyLabel(value) {
56 + const family = String(value || "").trim().toLowerCase();
57 + const a0Profile = family.endsWith("-a0");
58 + const remoteDebugging = family.endsWith("-cdp");
59 + const base = a0Profile ? family.slice(0, -3) : remoteDebugging ? family.slice(0, -4) : family;
60 + const labels = {
61 + chrome: "Chrome",
62 + chromium: "Chromium",
63 + edge: "Edge",
64 + "edge-dev": "Edge Dev",
65 + };
66 + const label = labels[base] || "Host browser";
67 + if (remoteDebugging) return `${label} (allowed)`;
68 + return a0Profile ? `${label} (A0 profile)` : label;
69 +}
70 +
71 +function hostBrowserStatusLabel(value) {
72 + const status = String(value || "").trim().toLowerCase();
73 + if (status === "active") return "open";
74 + if (status === "ready") return "ready";
75 + if (status === "disabled") return "will open on first use";
76 + if (status === "relaunch_required") return "close browser and retry";
77 + if (status === "unsupported") return "unavailable";
78 + return status || "ready";
79 +}
80 +
81 export const store = createStore("browserConfig", {
82 config: null,
83 extensionsList: [],
@@ -96,16 +122,16 @@ export const store = createStore("browserConfig", {
122
123 runtimeBackendLabel() {
124 const value = this.config?.runtime_backend || "container";
99 - if (value === "host_when_available") return "Host When Available";
100 - if (value === "host_required") return "Host Required";
101 - return "Container";
125 + if (value === "host_when_available") return "Use Host When Ready";
126 + if (value === "host_required") return "Require Host Browser";
127 + return "Docker Browser";
128 },
129
130 privacyPolicyLabel() {
131 const value = this.config?.host_browser_privacy_policy || "enforce_local";
106 - if (value === "warn") return "Warn";
132 + if (value === "warn") return "Warn When Using Cloud";
133 if (value === "allow") return "Allow";
108 - return "Enforce Local";
134 + return "Local Models Only";
135 },
136
137 async loadHostBrowserStatus() {
@@ -127,12 +153,13 @@ export const store = createStore("browserConfig", {
153 : [];
154 const active = connectors.find((item) => item?.supported && item?.enabled);
155 if (active) {
130 - const family = active.browser_family || "browser";
131 - const profile = active.profile_label ? ` / ${active.profile_label}` : "";
132 - return `${family}${profile}: ${active.status || "ready"}`;
156 + const profile = active.profile_label ? ` - ${active.profile_label}` : "";
157 + return `${hostBrowserFamilyLabel(active.browser_family)}${profile}: ${hostBrowserStatusLabel(active.status)}`;
158 }
134 - if (connectors.length) return "A0 CLI connected, host browser disabled or unavailable";
135 - return "Waiting for A0 CLI";
159 + const preparable = connectors.find((item) => item?.can_prepare || item?.supported);
160 + if (preparable) return "A0 CLI connected - browser will open on first use";
161 + if (connectors.length) return "A0 CLI connected - host browser unavailable";
162 + return "Connect A0 CLI to use a host browser";
163 },
164
165 hasPaths() {
plugins/_browser/webui/config.html
+10 -10
@@ -53,27 +53,27 @@
53 <div class="browser-config-card">
54 <div class="section-title">Host Browser</div>
55 <div class="section-description">
56 - Route the existing Browser tool through A0 CLI. When host mode is selected, the first browser action can prepare the local browser automatically.
56 + Use Chrome, Edge, or Chromium on this computer. Keep A0 CLI connected; the browser opens when the agent first needs it.
57 </div>
58
59 <label class="browser-config-field">
60 - <span class="browser-config-field-label">Backend mode</span>
60 + <span class="browser-config-field-label">Browser location</span>
61 <select x-model="$store.browserConfig.config.runtime_backend">
62 - <option value="container">Container</option>
63 - <option value="host_when_available">Host when available</option>
64 - <option value="host_required">Host required</option>
62 + <option value="container">Docker browser</option>
63 + <option value="host_when_available">Use host when ready</option>
64 + <option value="host_required">Require host browser</option>
65 </select>
66 - <span class="browser-config-field-help">The WebUI setting is the routing intent; CLI browser commands are available for diagnostics and manual override.</span>
66 + <span class="browser-config-field-help">Require host browser when pages must stay on this computer.</span>
67 </label>
68
69 <label class="browser-config-field">
70 - <span class="browser-config-field-label">Host content policy</span>
70 + <span class="browser-config-field-label">Page content access</span>
71 <select x-model="$store.browserConfig.config.host_browser_privacy_policy">
72 - <option value="enforce_local">Enforce local</option>
73 - <option value="warn">Warn</option>
72 + <option value="enforce_local">Local models only</option>
73 + <option value="warn">Warn when using cloud</option>
74 <option value="allow">Allow</option>
75 </select>
76 - <span class="browser-config-field-help">Local-model enforcement applies before host page content or screenshots are returned to the agent.</span>
76 + <span class="browser-config-field-help">Controls page text and screenshots from the host browser.</span>
77 </label>
78
79 <div class="browser-config-note">
tests/test_host_browser_connector.py
+59 -3
@@ -72,6 +72,60 @@ def test_host_browser_candidate_selection_allows_disabled_supported_cli():
72 ws_runtime.unregister_sid(sid)
73
74
75 +def test_host_browser_candidate_selection_allows_preparable_cli():
76 + sid = "sid-host-browser-preparable"
77 + context_id = "ctx-host-browser-preparable"
78 + ws_runtime.register_sid(sid)
79 + ws_runtime.subscribe_sid_to_context(sid, context_id)
80 + try:
81 + ws_runtime.store_sid_host_browser_metadata(
82 + sid,
83 + {
84 + "supported": False,
85 + "can_prepare": True,
86 + "enabled": False,
87 + "status": "unsupported",
88 + "browser_family": "chrome-a0",
89 + "profile_label": "Default",
90 + "features": ["ensure", "open"],
91 + "support_reason": "Python Playwright is not installed.",
92 + },
93 + )
94 +
95 + assert ws_runtime.select_host_browser_target_sid(context_id) is None
96 + assert ws_runtime.select_host_browser_candidate_sid(context_id) == sid
97 + rows = ws_runtime.host_browser_metadata_for_context(context_id)
98 + assert rows[0]["can_prepare"] is True
99 + finally:
100 + ws_runtime.unregister_sid(sid)
101 +
102 +
103 +def test_host_browser_metadata_infers_preparable_legacy_cli():
104 + sid = "sid-host-browser-legacy-preparable"
105 + context_id = "ctx-host-browser-legacy-preparable"
106 + ws_runtime.register_sid(sid)
107 + ws_runtime.subscribe_sid_to_context(sid, context_id)
108 + try:
109 + ws_runtime.store_sid_host_browser_metadata(
110 + sid,
111 + {
112 + "supported": False,
113 + "enabled": False,
114 + "status": "unsupported",
115 + "browser_family": "chrome-a0",
116 + "profile_label": "Default",
117 + "features": ["ensure", "open"],
118 + "support_reason": "Python Playwright is not installed.",
119 + },
120 + )
121 +
122 + rows = ws_runtime.host_browser_metadata_for_context(context_id)
123 + assert rows[0]["can_prepare"] is True
124 + assert ws_runtime.select_host_browser_candidate_sid(context_id) == sid
125 + finally:
126 + ws_runtime.unregister_sid(sid)
127 +
128 +
129 def test_pending_browser_op_resolves_and_disconnect_fails():
130 async def run() -> None:
131 sid = "sid-browser-pending"
@@ -205,7 +259,7 @@ def test_host_browser_artifact_materialization_rejects_oversized_payload(monkeyp
259 assert not list(tmp_path.rglob("shot.jpg"))
260
261
208 -def test_connector_runtime_ensures_disabled_host_browser_before_action(monkeypatch):
262 +def test_connector_runtime_ensures_preparable_host_browser_before_action(monkeypatch):
263 async def run() -> None:
264 import plugins._browser.helpers.connector_runtime as connector_runtime_module
265
@@ -251,12 +305,14 @@ def test_connector_runtime_ensures_disabled_host_browser_before_action(monkeypat
305 ws_runtime.store_sid_host_browser_metadata(
306 sid,
307 {
254 - "supported": True,
308 + "supported": False,
309 + "can_prepare": True,
310 "enabled": False,
256 - "status": "disabled",
311 + "status": "unsupported",
312 "browser_family": "chrome-a0",
313 "profile_label": "Default",
314 "features": ["ensure", "open"],
315 + "support_reason": "Python Playwright is not installed.",
316 },
317 )
318 runtime = ConnectorBrowserRuntime(context_id, _agent(context_id))