Refine host browser routing and settings copy
Some checks are pending
Build And Publish Docker Images / plan (push) Waiting to run
Build And Publish Docker Images / build (push) Blocked by required conditions

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.
This commit is contained in:
Alessandro 2026-05-08 06:37:32 +02:00
parent 4b3e2eb327
commit d47207dfd7
7 changed files with 155 additions and 37 deletions

View file

@ -51,7 +51,12 @@ to pair it with local-model enforcement for host-browser content.
1. Keep A0 CLI connected to the Agent Zero chat.
2. Optionally list or select a Chrome-family profile:
2. If you want Agent Zero to use an already-open personal Chrome window, open
`chrome://inspect/#remote-debugging` and click **Allow** for that browser
instance. A0 CLI detects Chrome's local `DevToolsActivePort` file; status and
profile checks do not connect to Chrome.
3. Optionally list or select a Chrome-family profile:
```bash
/browser profile
@ -60,12 +65,12 @@ to pair it with local-model enforcement for host-browser content.
```
Chrome 136+ blocks Playwright remote debugging against the default personal
Chrome data directory. In that case, choose the A0-controlled local profile
(`chrome-a0 Default` for Google Chrome). Cookies and site data remain in that
separate browser profile on the host, and the user may need to sign in there
once.
Chrome data directory. If Chrome's own Remote debugging consent path is not
available, choose the A0-controlled local profile (`chrome-a0 Default` for
Google Chrome). Cookies and site data remain in that separate browser profile on
the host, and the user may need to sign in there once.
3. In Agent Zero WebUI, open Browser plugin settings and choose one of:
4. In Agent Zero WebUI, open Browser plugin settings and choose one of:
- `container`: always use the Docker/server Playwright browser.
- `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:
/browser relaunch
```
4. If the selected Chrome profile is already open normally, A0 CLI reports
5. If the selected Chrome profile is already open normally, A0 CLI reports
`relaunch_required`. Close that browser and retry the agent request or run
`/browser relaunch` manually.
The MVP uses Python Playwright against installed system Chrome, Chromium, or
Edge. It does not require a Chrome extension, and it does not copy browser
credentials, cookies, or profile data out of the browser profile.
The local-profile launch path uses Python Playwright against installed system
Chrome, Chromium, or Edge. The user-authorized Chrome remote debugging path uses
A0 CLI's built-in DevTools Protocol helper instead, so users do not need to
install Chrome DevTools MCP. A0 does not copy browser credentials, cookies, or
profile data out of the browser profile.
Host-browser page content and screenshots are controlled by the Browser
plugin's project-level policy:

View file

@ -62,11 +62,13 @@ class ComputerUseMetadata:
@dataclass(frozen=True)
class HostBrowserMetadata:
supported: bool
can_prepare: bool
enabled: bool
status: str
browser_family: str
profile_label: str
profile_path: str
cdp_endpoint: str
features: tuple[str, ...]
support_reason: str
updated_at: float
@ -359,15 +361,18 @@ def store_sid_host_browser_metadata(sid: str, payload: dict[str, Any]) -> HostBr
features = tuple(str(item).strip() for item in features_value if str(item).strip())
else:
features = ()
support_reason = str(payload.get("support_reason", "") or "").strip()
metadata = HostBrowserMetadata(
supported=bool(payload.get("supported")),
can_prepare=_host_browser_can_prepare(payload, features=features, support_reason=support_reason),
enabled=bool(payload.get("supported")) and bool(payload.get("enabled")),
status=str(payload.get("status", "") or "").strip(),
browser_family=str(payload.get("browser_family", "") or "").strip(),
profile_label=str(payload.get("profile_label", "") or "").strip(),
profile_path=str(payload.get("profile_path", "") or "").strip(),
cdp_endpoint=str(payload.get("cdp_endpoint", "") or "").strip(),
features=features,
support_reason=str(payload.get("support_reason", "") or "").strip(),
support_reason=support_reason,
updated_at=time.time(),
)
with _state_lock:
@ -375,6 +380,25 @@ def store_sid_host_browser_metadata(sid: str, payload: dict[str, Any]) -> HostBr
return metadata
def _host_browser_can_prepare(
payload: dict[str, Any],
*,
features: tuple[str, ...],
support_reason: str,
) -> bool:
if "can_prepare" in payload:
return bool(payload.get("can_prepare"))
if "ensure" not in features:
return False
reason = support_reason.lower()
return (
"python playwright" in reason
or "a0-controlled local profile" in reason
or "chrome-a0" in reason
or "remote debugging" in reason
)
def clear_sid_host_browser_metadata(sid: str) -> None:
with _state_lock:
_sid_host_browser_metadata.pop(sid, None)
@ -387,11 +411,13 @@ def host_browser_metadata_for_sid(sid: str) -> dict[str, Any] | None:
return None
return {
"supported": metadata.supported,
"can_prepare": metadata.can_prepare,
"enabled": metadata.enabled,
"status": metadata.status,
"browser_family": metadata.browser_family,
"profile_label": metadata.profile_label,
"profile_path": metadata.profile_path,
"cdp_endpoint": metadata.cdp_endpoint,
"features": list(metadata.features),
"support_reason": metadata.support_reason,
"updated_at": metadata.updated_at,
@ -421,7 +447,7 @@ def select_host_browser_candidate_sid(context_id: str) -> str | None:
fallback: str | None = None
for sid in subscribers:
metadata = _sid_host_browser_metadata.get(sid)
if not metadata or not metadata.supported:
if not metadata or not (metadata.supported or metadata.can_prepare):
continue
if metadata.enabled and metadata.status in {"ready", "active"}:
return sid

View file

@ -328,7 +328,8 @@ class ConnectorBrowserRuntime:
for status in statuses:
parts.append(
f"sid={status.get('sid')} status={status.get('status')} "
f"supported={status.get('supported')} enabled={status.get('enabled')} "
f"supported={status.get('supported')} can_prepare={status.get('can_prepare')} "
f"enabled={status.get('enabled')} "
f"reason={status.get('support_reason') or 'none'}"
)
return "; ".join(parts)

View file

@ -59,7 +59,8 @@ def _host_browser_status_detail(context_id: str) -> str:
for status in statuses:
parts.append(
f"sid={status.get('sid')} supported={status.get('supported')} "
f"enabled={status.get('enabled')} status={status.get('status') or 'unknown'} "
f"can_prepare={status.get('can_prepare')} enabled={status.get('enabled')} "
f"status={status.get('status') or 'unknown'} "
f"reason={status.get('support_reason') or 'none'}"
)
return "; ".join(parts)

View file

@ -52,6 +52,32 @@ function normalizeBoolean(value, fallback = true) {
return fallback;
}
function hostBrowserFamilyLabel(value) {
const family = String(value || "").trim().toLowerCase();
const a0Profile = family.endsWith("-a0");
const remoteDebugging = family.endsWith("-cdp");
const base = a0Profile ? family.slice(0, -3) : remoteDebugging ? family.slice(0, -4) : family;
const labels = {
chrome: "Chrome",
chromium: "Chromium",
edge: "Edge",
"edge-dev": "Edge Dev",
};
const label = labels[base] || "Host browser";
if (remoteDebugging) return `${label} (allowed)`;
return a0Profile ? `${label} (A0 profile)` : label;
}
function hostBrowserStatusLabel(value) {
const status = String(value || "").trim().toLowerCase();
if (status === "active") return "open";
if (status === "ready") return "ready";
if (status === "disabled") return "will open on first use";
if (status === "relaunch_required") return "close browser and retry";
if (status === "unsupported") return "unavailable";
return status || "ready";
}
export const store = createStore("browserConfig", {
config: null,
extensionsList: [],
@ -96,16 +122,16 @@ export const store = createStore("browserConfig", {
runtimeBackendLabel() {
const value = this.config?.runtime_backend || "container";
if (value === "host_when_available") return "Host When Available";
if (value === "host_required") return "Host Required";
return "Container";
if (value === "host_when_available") return "Use Host When Ready";
if (value === "host_required") return "Require Host Browser";
return "Docker Browser";
},
privacyPolicyLabel() {
const value = this.config?.host_browser_privacy_policy || "enforce_local";
if (value === "warn") return "Warn";
if (value === "warn") return "Warn When Using Cloud";
if (value === "allow") return "Allow";
return "Enforce Local";
return "Local Models Only";
},
async loadHostBrowserStatus() {
@ -127,12 +153,13 @@ export const store = createStore("browserConfig", {
: [];
const active = connectors.find((item) => item?.supported && item?.enabled);
if (active) {
const family = active.browser_family || "browser";
const profile = active.profile_label ? ` / ${active.profile_label}` : "";
return `${family}${profile}: ${active.status || "ready"}`;
const profile = active.profile_label ? ` - ${active.profile_label}` : "";
return `${hostBrowserFamilyLabel(active.browser_family)}${profile}: ${hostBrowserStatusLabel(active.status)}`;
}
if (connectors.length) return "A0 CLI connected, host browser disabled or unavailable";
return "Waiting for A0 CLI";
const preparable = connectors.find((item) => item?.can_prepare || item?.supported);
if (preparable) return "A0 CLI connected - browser will open on first use";
if (connectors.length) return "A0 CLI connected - host browser unavailable";
return "Connect A0 CLI to use a host browser";
},
hasPaths() {

View file

@ -53,27 +53,27 @@
<div class="browser-config-card">
<div class="section-title">Host Browser</div>
<div class="section-description">
Route the existing Browser tool through A0 CLI. When host mode is selected, the first browser action can prepare the local browser automatically.
Use Chrome, Edge, or Chromium on this computer. Keep A0 CLI connected; the browser opens when the agent first needs it.
</div>
<label class="browser-config-field">
<span class="browser-config-field-label">Backend mode</span>
<span class="browser-config-field-label">Browser location</span>
<select x-model="$store.browserConfig.config.runtime_backend">
<option value="container">Container</option>
<option value="host_when_available">Host when available</option>
<option value="host_required">Host required</option>
<option value="container">Docker browser</option>
<option value="host_when_available">Use host when ready</option>
<option value="host_required">Require host browser</option>
</select>
<span class="browser-config-field-help">The WebUI setting is the routing intent; CLI browser commands are available for diagnostics and manual override.</span>
<span class="browser-config-field-help">Require host browser when pages must stay on this computer.</span>
</label>
<label class="browser-config-field">
<span class="browser-config-field-label">Host content policy</span>
<span class="browser-config-field-label">Page content access</span>
<select x-model="$store.browserConfig.config.host_browser_privacy_policy">
<option value="enforce_local">Enforce local</option>
<option value="warn">Warn</option>
<option value="enforce_local">Local models only</option>
<option value="warn">Warn when using cloud</option>
<option value="allow">Allow</option>
</select>
<span class="browser-config-field-help">Local-model enforcement applies before host page content or screenshots are returned to the agent.</span>
<span class="browser-config-field-help">Controls page text and screenshots from the host browser.</span>
</label>
<div class="browser-config-note">

View file

@ -72,6 +72,60 @@ def test_host_browser_candidate_selection_allows_disabled_supported_cli():
ws_runtime.unregister_sid(sid)
def test_host_browser_candidate_selection_allows_preparable_cli():
sid = "sid-host-browser-preparable"
context_id = "ctx-host-browser-preparable"
ws_runtime.register_sid(sid)
ws_runtime.subscribe_sid_to_context(sid, context_id)
try:
ws_runtime.store_sid_host_browser_metadata(
sid,
{
"supported": False,
"can_prepare": True,
"enabled": False,
"status": "unsupported",
"browser_family": "chrome-a0",
"profile_label": "Default",
"features": ["ensure", "open"],
"support_reason": "Python Playwright is not installed.",
},
)
assert ws_runtime.select_host_browser_target_sid(context_id) is None
assert ws_runtime.select_host_browser_candidate_sid(context_id) == sid
rows = ws_runtime.host_browser_metadata_for_context(context_id)
assert rows[0]["can_prepare"] is True
finally:
ws_runtime.unregister_sid(sid)
def test_host_browser_metadata_infers_preparable_legacy_cli():
sid = "sid-host-browser-legacy-preparable"
context_id = "ctx-host-browser-legacy-preparable"
ws_runtime.register_sid(sid)
ws_runtime.subscribe_sid_to_context(sid, context_id)
try:
ws_runtime.store_sid_host_browser_metadata(
sid,
{
"supported": False,
"enabled": False,
"status": "unsupported",
"browser_family": "chrome-a0",
"profile_label": "Default",
"features": ["ensure", "open"],
"support_reason": "Python Playwright is not installed.",
},
)
rows = ws_runtime.host_browser_metadata_for_context(context_id)
assert rows[0]["can_prepare"] is True
assert ws_runtime.select_host_browser_candidate_sid(context_id) == sid
finally:
ws_runtime.unregister_sid(sid)
def test_pending_browser_op_resolves_and_disconnect_fails():
async def run() -> None:
sid = "sid-browser-pending"
@ -205,7 +259,7 @@ def test_host_browser_artifact_materialization_rejects_oversized_payload(monkeyp
assert not list(tmp_path.rglob("shot.jpg"))
def test_connector_runtime_ensures_disabled_host_browser_before_action(monkeypatch):
def test_connector_runtime_ensures_preparable_host_browser_before_action(monkeypatch):
async def run() -> None:
import plugins._browser.helpers.connector_runtime as connector_runtime_module
@ -251,12 +305,14 @@ def test_connector_runtime_ensures_disabled_host_browser_before_action(monkeypat
ws_runtime.store_sid_host_browser_metadata(
sid,
{
"supported": True,
"supported": False,
"can_prepare": True,
"enabled": False,
"status": "disabled",
"status": "unsupported",
"browser_family": "chrome-a0",
"profile_label": "Default",
"features": ["ensure", "open"],
"support_reason": "Python Playwright is not installed.",
},
)
runtime = ConnectorBrowserRuntime(context_id, _agent(context_id))