From 7298a88fda26bf3e1b45dd9570195594defe7af9 Mon Sep 17 00:00:00 2001 From: Alessandro <155005371+3clyp50@users.noreply.github.com> Date: Sat, 4 Jul 2026 18:33:58 +0200 Subject: [PATCH] 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. --- plugins/_a0_connector/AGENTS.md | 1 + .../_a0_connector/api/v1/browser_runtime.py | 11 ++++++ plugins/_a0_connector/helpers/ws_runtime.py | 39 +++++++++++++++++++ plugins/_browser/AGENTS.md | 1 + plugins/_browser/default_config.yaml | 5 +++ plugins/_browser/helpers/config.py | 13 +++++++ plugins/_browser/helpers/connector_runtime.py | 11 ++++++ .../_browser/webui/browser-config-store.js | 39 +++++++++++++++++++ plugins/_browser/webui/config.html | 16 ++++++++ tests/test_browser_agent_regressions.py | 22 +++++++++++ 10 files changed, 158 insertions(+) diff --git a/plugins/_a0_connector/AGENTS.md b/plugins/_a0_connector/AGENTS.md index e013c73cf..b81567a24 100644 --- a/plugins/_a0_connector/AGENTS.md +++ b/plugins/_a0_connector/AGENTS.md @@ -24,6 +24,7 @@ - File operation results may arrive as chunked JSON/base64 `connector_file_op_result` frames; resolve the pending file operation only after all chunks for the `op_id` are assembled. +- 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. ## Work Guidance diff --git a/plugins/_a0_connector/api/v1/browser_runtime.py b/plugins/_a0_connector/api/v1/browser_runtime.py index 72467e954..ae9b63829 100644 --- a/plugins/_a0_connector/api/v1/browser_runtime.py +++ b/plugins/_a0_connector/api/v1/browser_runtime.py @@ -24,6 +24,11 @@ def _normalize_requested_backend(value: object) -> str: return "" +def _normalize_host_browser_selection(value: object) -> str: + raw = _string(value).lower().replace(" ", "_") + return "".join(ch for ch in raw if ch.isalnum() or ch in {"_", "-", ":", ".", "/"})[:200] + + def _normalize_profile_mode(value: object) -> str: normalized = _string(value).lower().replace("-", "_").replace(" ", "_") if normalized in {"agent", "clean", "clean_agent", "a0", "dedicated"}: @@ -68,6 +73,10 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler): mimetype="application/json", ) settings["runtime_backend"] = runtime_backend + if "host_browser_selection" in input or "browser_selection" in input: + settings["host_browser_selection"] = _normalize_host_browser_selection( + input.get("host_browser_selection", input.get("browser_selection")) + ) if "host_browser_profile_mode" in input or "profile_mode" in input: profile_mode = _normalize_profile_mode( input.get("host_browser_profile_mode", input.get("profile_mode")) @@ -86,11 +95,13 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler): runtime_backend = settings.get("runtime_backend") or "container" profile_mode = _normalize_profile_mode(settings.get("host_browser_profile_mode")) or "existing" + browser_selection = _normalize_host_browser_selection(settings.get("host_browser_selection")) return { "ok": True, "runtime_backend": runtime_backend, "host_browser_profile_mode": profile_mode, + "host_browser_selection": browser_selection, "label": _runtime_label(runtime_backend), "project_name": project_name, "agent_profile": "", diff --git a/plugins/_a0_connector/helpers/ws_runtime.py b/plugins/_a0_connector/helpers/ws_runtime.py index 2e6b7034f..e35948359 100644 --- a/plugins/_a0_connector/helpers/ws_runtime.py +++ b/plugins/_a0_connector/helpers/ws_runtime.py @@ -80,6 +80,9 @@ class HostBrowserMetadata: profile_label: str profile_path: str cdp_endpoint: str + browser_id: str + browser_label: str + available_browsers: tuple[dict[str, Any], ...] content_helper_sha256: str features: tuple[str, ...] support_reason: str @@ -416,6 +419,9 @@ def store_sid_host_browser_metadata(sid: str, payload: dict[str, Any]) -> HostBr 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(), + browser_id=str(payload.get("browser_id", payload.get("browser_selection", "")) or "").strip(), + browser_label=str(payload.get("browser_label", "") or "").strip(), + available_browsers=_normalize_available_host_browsers(payload.get("available_browsers")), content_helper_sha256=str(payload.get("content_helper_sha256", "") or "").strip().lower(), features=features, support_reason=support_reason, @@ -445,6 +451,32 @@ def _host_browser_can_prepare( ) +def _normalize_available_host_browsers(value: Any) -> tuple[dict[str, Any], ...]: + if not isinstance(value, (list, tuple)): + return () + browsers: list[dict[str, Any]] = [] + for item in value: + if not isinstance(item, dict): + continue + browser_id = str(item.get("id", item.get("browser_id", item.get("selection", ""))) or "").strip() + family = str(item.get("family", item.get("browser_family", "")) or "").strip() + label = str(item.get("label", item.get("name", "")) or "").strip() + cdp_endpoint = str(item.get("cdp_endpoint", "") or "").strip() + status = str(item.get("status", "") or "").strip() + enabled = bool(item.get("enabled", True)) + if not any((browser_id, family, label, cdp_endpoint)): + continue + browsers.append({ + "id": browser_id or family or cdp_endpoint, + "family": family, + "label": label or family or browser_id or cdp_endpoint, + "cdp_endpoint": cdp_endpoint, + "status": status, + "enabled": enabled, + }) + return tuple(browsers) + + def clear_sid_host_browser_metadata(sid: str) -> None: with _state_lock: _sid_host_browser_metadata.pop(sid, None) @@ -464,6 +496,9 @@ def host_browser_metadata_for_sid(sid: str) -> dict[str, Any] | None: "profile_label": metadata.profile_label, "profile_path": metadata.profile_path, "cdp_endpoint": metadata.cdp_endpoint, + "browser_id": metadata.browser_id, + "browser_label": metadata.browser_label, + "available_browsers": copy.deepcopy(list(metadata.available_browsers)), "content_helper_sha256": metadata.content_helper_sha256, "features": list(metadata.features), "support_reason": metadata.support_reason, @@ -529,6 +564,10 @@ def all_host_browser_metadata() -> list[dict[str, Any]]: "browser_family": metadata.browser_family, "profile_label": metadata.profile_label, "profile_path": metadata.profile_path, + "cdp_endpoint": metadata.cdp_endpoint, + "browser_id": metadata.browser_id, + "browser_label": metadata.browser_label, + "available_browsers": copy.deepcopy(list(metadata.available_browsers)), "content_helper_sha256": metadata.content_helper_sha256, "features": list(metadata.features), "support_reason": metadata.support_reason, diff --git a/plugins/_browser/AGENTS.md b/plugins/_browser/AGENTS.md index 075bdede5..504c46962 100644 --- a/plugins/_browser/AGENTS.md +++ b/plugins/_browser/AGENTS.md @@ -24,6 +24,7 @@ - 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. - 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`. - Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar. +- 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`. - Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces. - Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback. - Do not hardcode user-specific browser paths or secrets. diff --git a/plugins/_browser/default_config.yaml b/plugins/_browser/default_config.yaml index e4416d0fa..896239304 100644 --- a/plugins/_browser/default_config.yaml +++ b/plugins/_browser/default_config.yaml @@ -33,6 +33,11 @@ host_browser_privacy_policy: "allow" # - agent: use a clean A0-controlled browser profile on the host. host_browser_profile_mode: "existing" +# Optional host browser target when using an existing browser. +# Empty means A0 CLI chooses the first supported/active browser. +# Values may be browser family ids (chrome, edge, chromium) or CLI-advertised ids/endpoints. +host_browser_selection: "" + # Optional _model_config preset used by Browser-owned model helpers. # Empty uses the effective Main Model. model_preset: "" diff --git a/plugins/_browser/helpers/config.py b/plugins/_browser/helpers/config.py index a4a052aed..5279f6215 100644 --- a/plugins/_browser/helpers/config.py +++ b/plugins/_browser/helpers/config.py @@ -16,6 +16,7 @@ MAX_OPEN_TABS_KEY = "max_open_tabs" RUNTIME_BACKEND_KEY = "runtime_backend" HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy" HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode" +HOST_BROWSER_SELECTION_KEY = "host_browser_selection" RUNTIME_BACKENDS = {"container", "host_required"} BROWSER_TAB_SCOPES = {"per_context", "shared"} HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"} @@ -58,6 +59,15 @@ def _normalize_model_preset(value: Any) -> str: return str(value or "").strip() +def _normalize_host_browser_selection(value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + normalized = raw.lower().replace(" ", "_") + # Keep explicit CLI ids/ports/endpoints usable while avoiding control characters. + return "".join(ch for ch in normalized if ch.isalnum() or ch in {"_", "-", ":", ".", "/"})[:200] + + def _normalize_default_homepage(value: Any) -> str: homepage = str(value or "").strip() return homepage or "about:blank" @@ -144,6 +154,9 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]: allowed=HOST_BROWSER_PROFILE_MODES, default="existing", ), + HOST_BROWSER_SELECTION_KEY: _normalize_host_browser_selection( + raw.get(HOST_BROWSER_SELECTION_KEY, raw.get("host_browser_choice", "")) + ), MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")), } diff --git a/plugins/_browser/helpers/connector_runtime.py b/plugins/_browser/helpers/connector_runtime.py index 47b31512b..3eb19b063 100644 --- a/plugins/_browser/helpers/connector_runtime.py +++ b/plugins/_browser/helpers/connector_runtime.py @@ -57,6 +57,11 @@ HOST_BROWSER_PROFILE_MODE_KEY = getattr( "HOST_BROWSER_PROFILE_MODE_KEY", "host_browser_profile_mode", ) +HOST_BROWSER_SELECTION_KEY = getattr( + browser_config, + "HOST_BROWSER_SELECTION_KEY", + "host_browser_selection", +) get_browser_config = browser_config.get_browser_config _LOCAL_PROVIDERS = {"ollama", "lm_studio", "llama_cpp", "omlx", "vllm"} _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"} @@ -123,6 +128,7 @@ class ConnectorBrowserRuntime: "context_id": self.context_id, "action": action, "profile_mode": self._host_browser_profile_mode(), + "browser_selection": self._host_browser_selection(), } if action == "open": @@ -283,6 +289,7 @@ class ConnectorBrowserRuntime: "context_id": self.context_id, "action": "ensure", "profile_mode": self._host_browser_profile_mode(), + "browser_selection": self._host_browser_selection(), }, ), ) @@ -295,6 +302,10 @@ class ConnectorBrowserRuntime: mode = str(config.get(HOST_BROWSER_PROFILE_MODE_KEY) or "existing").strip().lower() return "agent" if mode == "agent" else "existing" + def _host_browser_selection(self) -> str: + config = get_browser_config(self.agent) + return str(config.get(HOST_BROWSER_SELECTION_KEY) or "").strip() + def _with_content_helper(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]: return self._with_browser_helpers(sid, payload) diff --git a/plugins/_browser/webui/browser-config-store.js b/plugins/_browser/webui/browser-config-store.js index aea9fac14..9779e8bb2 100644 --- a/plugins/_browser/webui/browser-config-store.js +++ b/plugins/_browser/webui/browser-config-store.js @@ -44,6 +44,7 @@ function ensureConfig(config) { HOST_PROFILE_MODES, "existing", ); + config.host_browser_selection = normalizeHostBrowserSelection(config.host_browser_selection); config.model_preset = String(config.model_preset || "").trim(); delete config.model; return config; @@ -66,6 +67,10 @@ function normalizeRuntimeBackend(value) { return RUNTIME_BACKENDS.has(normalized) ? normalized : "container"; } +function normalizeHostBrowserSelection(value) { + return String(value || "").trim().toLowerCase().replace(/\s+/g, "_").slice(0, 200); +} + function normalizeBoolean(value, fallback = true) { if (value === undefined || value === null || value === "") return fallback; if (typeof value === "boolean") return value; @@ -178,6 +183,40 @@ export const store = createStore("browserConfig", { return "Local Models Only"; }, + hostBrowserOptions() { + const connectors = Array.isArray(this.hostBrowserStatus?.connectors) + ? this.hostBrowserStatus.connectors + : []; + const options = [{ value: "", label: "Automatic (A0 CLI chooses)" }]; + const seen = new Set([""]); + for (const connector of connectors) { + const advertised = Array.isArray(connector?.available_browsers) + ? connector.available_browsers + : []; + for (const browser of advertised) { + const value = normalizeHostBrowserSelection(browser?.id || browser?.family || browser?.cdp_endpoint); + if (!value || seen.has(value)) continue; + seen.add(value); + const label = browser?.label || hostBrowserFamilyLabel(browser?.family || value); + const status = browser?.status ? ` - ${hostBrowserStatusLabel(browser.status)}` : ""; + options.push({ value, label: `${label}${status}` }); + } + const fallbackValue = normalizeHostBrowserSelection(connector?.browser_id || connector?.browser_family); + if (fallbackValue && !seen.has(fallbackValue)) { + seen.add(fallbackValue); + const label = connector?.browser_label || hostBrowserFamilyLabel(connector?.browser_family || fallbackValue); + options.push({ value: fallbackValue, label }); + } + } + return options; + }, + + setHostBrowserSelection(value) { + const safeConfig = ensureConfig(this.config); + if (!safeConfig) return; + safeConfig.host_browser_selection = normalizeHostBrowserSelection(value); + }, + hostBrowserProfileModeLabel() { const value = this.config?.host_browser_profile_mode || "existing"; if (value === "agent") return "Clean Agent Profile"; diff --git a/plugins/_browser/webui/config.html b/plugins/_browser/webui/config.html index 67947e511..f01588f25 100644 --- a/plugins/_browser/webui/config.html +++ b/plugins/_browser/webui/config.html @@ -66,6 +66,22 @@ + +