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))