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