Expose Browser runtime selection to CLI

Add a protected connector endpoint for reading and updating the Browser plugin runtime backend so the A0 CLI can switch between Docker browser and Bring Your Own Browser mode. Keep legacy host_when_available values normalized to host_required, move the host/container setting to the top of Browser settings, and cover the config normalization path.

Alessandro committed May 8, 2026 at 18:37 UTC 229de5166b96ef38ee6e25c80eee27d4dbf39838
5 files changed +148 -40
plugins/_a0_connector/api/v1/browser_runtime.py new
+98
@@ -0,0 +1,98 @@
1 +"""POST /api/plugins/_a0_connector/v1/browser_runtime."""
2 +from __future__ import annotations
3 +
4 +from helpers.api import Request, Response
5 +import plugins._a0_connector.api.v1.base as connector_base
6 +
7 +
8 +_PRIVACY_NOTICE = (
9 + "For GDPR/content policy, visit Agent Zero WebUI > Browser settings to choose "
10 + "Local models only, Warn when using cloud, or Allow."
11 +)
12 +
13 +
14 +def _string(value: object) -> str:
15 + return str(value or "").strip()
16 +
17 +
18 +def _normalize_requested_backend(value: object) -> str:
19 + normalized = _string(value).lower().replace("-", "_").replace(" ", "_")
20 + if normalized in {"container", "docker", "docker_container"}:
21 + return "container"
22 + if normalized in {"host", "host_required", "byob", "bring_your_own_browser"}:
23 + return "host_required"
24 + return ""
25 +
26 +
27 +def _runtime_label(value: str) -> str:
28 + if value == "host_required":
29 + return "Bring Your Own Browser"
30 + return "Docker browser"
31 +
32 +
33 +class BrowserRuntime(connector_base.ProtectedConnectorApiHandler):
34 + async def process(self, input: dict, request: Request) -> dict | Response:
35 + action = _string(input.get("action")).lower() or "get"
36 + if action not in {"get", "set"}:
37 + return Response(
38 + response='{"error":"Unsupported action"}',
39 + status=400,
40 + mimetype="application/json",
41 + )
42 +
43 + try:
44 + project_name = self._project_name_for_context(_string(input.get("context_id")))
45 + except LookupError:
46 + return Response(
47 + response='{"error":"Context not found"}',
48 + status=404,
49 + mimetype="application/json",
50 + )
51 +
52 + settings = self._load_browser_config(project_name)
53 + if action == "set":
54 + runtime_backend = _normalize_requested_backend(input.get("runtime_backend"))
55 + if not runtime_backend:
56 + return Response(
57 + response='{"error":"runtime_backend must be host or container"}',
58 + status=400,
59 + mimetype="application/json",
60 + )
61 + settings["runtime_backend"] = runtime_backend
62 + self._save_browser_config(project_name, settings)
63 +
64 + return {
65 + "ok": True,
66 + "runtime_backend": settings["runtime_backend"],
67 + "label": _runtime_label(settings["runtime_backend"]),
68 + "project_name": project_name,
69 + "agent_profile": "",
70 + "privacy_notice": _PRIVACY_NOTICE,
71 + }
72 +
73 + def _project_name_for_context(self, context_id: str) -> str:
74 + if not context_id:
75 + return ""
76 +
77 + from agent import AgentContext
78 + from helpers import projects
79 +
80 + context = AgentContext.get(context_id)
81 + if context is None:
82 + raise LookupError(context_id)
83 + return projects.get_context_project_name(context) or ""
84 +
85 + def _load_browser_config(self, project_name: str) -> dict:
86 + from helpers import plugins
87 + from plugins._browser.helpers.config import PLUGIN_NAME, normalize_browser_config
88 +
89 + return normalize_browser_config(
90 + plugins.get_plugin_config(PLUGIN_NAME, project_name=project_name, agent_profile="")
91 + or {}
92 + )
93 +
94 + def _save_browser_config(self, project_name: str, settings: dict) -> None:
95 + from helpers import plugins
96 + from plugins._browser.helpers.config import PLUGIN_NAME
97 +
98 + plugins.save_plugin_config(PLUGIN_NAME, project_name, "", settings)
plugins/_a0_connector/api/v1/capabilities.py
+1
@@ -37,6 +37,7 @@ _OPTIONAL_FEATURES: dict[str, tuple[str, ...]] = {
37 "skills_delete": ("helpers.skills", "helpers.files", "helpers.projects", "helpers.runtime"),
38 "model_presets": ("plugins._model_config.helpers.model_config",),
39 "model_switcher": ("plugins._model_config.helpers.model_config",),
40 + "browser_runtime_config": ("plugins._browser.helpers.config", "helpers.plugins"),
41 "compact_chat": (
42 "plugins._chat_compaction.helpers.compactor",
43 "plugins._model_config.helpers.model_config",
plugins/_browser/helpers/config.py
+10 -5
@@ -13,7 +13,7 @@ DEFAULT_HOMEPAGE_KEY = "default_homepage"
13 AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
14 RUNTIME_BACKEND_KEY = "runtime_backend"
15 HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy"
16 -RUNTIME_BACKENDS = {"container", "host_when_available", "host_required"}
16 +RUNTIME_BACKENDS = {"container", "host_required"}
17 HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
18 BASE_BROWSER_ARGS = [
19 "--no-sandbox",
@@ -75,6 +75,13 @@ def _normalize_choice(value: Any, *, allowed: set[str], default: str) -> str:
75 return default
76
77
78 +def _normalize_runtime_backend(value: Any) -> str:
79 + normalized = str(value or "").strip().lower().replace("-", "_")
80 + if normalized == "host_when_available":
81 + return "host_required"
82 + return _normalize_choice(normalized, allowed=RUNTIME_BACKENDS, default="container")
83 +
84 +
85 def _model_config_summary(config: dict[str, Any] | None) -> str:
86 if not isinstance(config, dict):
87 return ""
@@ -95,10 +102,8 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
102 raw.get(AUTOFOCUS_ACTIVE_PAGE_KEY, True),
103 default=True,
104 ),
98 - RUNTIME_BACKEND_KEY: _normalize_choice(
99 - raw.get(RUNTIME_BACKEND_KEY, "container"),
100 - allowed=RUNTIME_BACKENDS,
101 - default="container",
105 + RUNTIME_BACKEND_KEY: _normalize_runtime_backend(
106 + raw.get(RUNTIME_BACKEND_KEY, "container")
107 ),
108 HOST_BROWSER_PRIVACY_POLICY_KEY: _normalize_choice(
109 raw.get(HOST_BROWSER_PRIVACY_POLICY_KEY, "enforce_local"),
plugins/_browser/webui/config.html
+35 -35
@@ -15,41 +15,6 @@
15 x-effect="$store.browserConfig.bindConfig(config)"
16 x-destroy="$store.browserConfig.cleanup()"
17 >
18 - <div class="browser-config-card">
19 - <div class="section-title">Browsing</div>
20 - <div class="section-description">
21 - Set how new Browser sessions start and how an already-open Browser surface follows agent activity.
22 - </div>
23 -
24 - <label class="browser-config-field">
25 - <span class="browser-config-field-label">Starting page</span>
26 - <input
27 - type="text"
28 - x-model="$store.browserConfig.config.default_homepage"
29 - placeholder="about:blank or https://example.com"
30 - autocomplete="off"
31 - />
32 - </label>
33 -
34 - <label class="browser-config-switch-row">
35 - <span class="browser-config-switch-copy">
36 - <span class="browser-config-field-label">Autofocus active page</span>
37 - <span class="browser-config-field-help">Update the visible Browser surface for pages opened or changed by Browser tool results.</span>
38 - </span>
39 - <span class="browser-config-toggle-with-label">
40 - <span class="browser-config-toggle-label" x-text="$store.browserConfig.autofocusLabel()"></span>
41 - <span class="browser-config-toggle">
42 - <input
43 - type="checkbox"
44 - :checked="$store.browserConfig.config.autofocus_active_page !== false"
45 - @change="$store.browserConfig.setAutofocusActivePage($event.target.checked)"
46 - />
47 - <span class="browser-config-switch"></span>
48 - </span>
49 - </span>
50 - </label>
51 - </div>
52 -
18 <div class="browser-config-card">
19 <div class="section-title">Host Browser</div>
20 <div class="section-description">
@@ -86,6 +51,41 @@
51 </div>
52 </div>
53
54 + <div class="browser-config-card">
55 + <div class="section-title">Browsing</div>
56 + <div class="section-description">
57 + Set how new Browser sessions start and how an already-open Browser surface follows agent activity.
58 + </div>
59 +
60 + <label class="browser-config-field">
61 + <span class="browser-config-field-label">Starting page</span>
62 + <input
63 + type="text"
64 + x-model="$store.browserConfig.config.default_homepage"
65 + placeholder="about:blank or https://example.com"
66 + autocomplete="off"
67 + />
68 + </label>
69 +
70 + <label class="browser-config-switch-row">
71 + <span class="browser-config-switch-copy">
72 + <span class="browser-config-field-label">Autofocus active page</span>
73 + <span class="browser-config-field-help">Update the visible Browser surface for pages opened or changed by Browser tool results.</span>
74 + </span>
75 + <span class="browser-config-toggle-with-label">
76 + <span class="browser-config-toggle-label" x-text="$store.browserConfig.autofocusLabel()"></span>
77 + <span class="browser-config-toggle">
78 + <input
79 + type="checkbox"
80 + :checked="$store.browserConfig.config.autofocus_active_page !== false"
81 + @change="$store.browserConfig.setAutofocusActivePage($event.target.checked)"
82 + />
83 + <span class="browser-config-switch"></span>
84 + </span>
85 + </span>
86 + </label>
87 + </div>
88 +
89 <div class="browser-config-card">
90 <div class="section-title">Extensions</div>
91 <div class="section-description">
tests/test_browser_agent_regressions.py
+4
@@ -174,6 +174,10 @@ def test_browser_config_normalizes_host_backend_and_privacy_policy():
174
175 assert config["runtime_backend"] == "host_required"
176 assert config["host_browser_privacy_policy"] == "warn"
177 + assert (
178 + normalize_browser_config({"runtime_backend": "host_when_available"})["runtime_backend"]
179 + == "host_required"
180 + )
181
182
183 def test_browser_model_selection_uses_presets(monkeypatch):