diff --git a/docs/guides/a0-cli-connector.md b/docs/guides/a0-cli-connector.md index a58968697..51930f164 100644 --- a/docs/guides/a0-cli-connector.md +++ b/docs/guides/a0-cli-connector.md @@ -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: diff --git a/plugins/_a0_connector/helpers/ws_runtime.py b/plugins/_a0_connector/helpers/ws_runtime.py index 4d5345ea7..f311dd3bd 100644 --- a/plugins/_a0_connector/helpers/ws_runtime.py +++ b/plugins/_a0_connector/helpers/ws_runtime.py @@ -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 diff --git a/plugins/_browser/helpers/connector_runtime.py b/plugins/_browser/helpers/connector_runtime.py index 688a76884..3cd5baa98 100644 --- a/plugins/_browser/helpers/connector_runtime.py +++ b/plugins/_browser/helpers/connector_runtime.py @@ -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) diff --git a/plugins/_browser/helpers/selector.py b/plugins/_browser/helpers/selector.py index 7270592b2..061f8c977 100644 --- a/plugins/_browser/helpers/selector.py +++ b/plugins/_browser/helpers/selector.py @@ -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) diff --git a/plugins/_browser/webui/browser-config-store.js b/plugins/_browser/webui/browser-config-store.js index 3c5258ef1..ce9484855 100644 --- a/plugins/_browser/webui/browser-config-store.js +++ b/plugins/_browser/webui/browser-config-store.js @@ -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() { diff --git a/plugins/_browser/webui/config.html b/plugins/_browser/webui/config.html index 619260d01..33e5bca47 100644 --- a/plugins/_browser/webui/config.html +++ b/plugins/_browser/webui/config.html @@ -53,27 +53,27 @@
Host Browser
- 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.
diff --git a/tests/test_host_browser_connector.py b/tests/test_host_browser_connector.py index dfd48b02c..7819ca38b 100644 --- a/tests/test_host_browser_connector.py +++ b/tests/test_host_browser_connector.py @@ -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))