Add host browser profile mode setting

Default Bring Your Own Browser mode to the existing browser profile while exposing a clean Agent profile option in Browser settings with a clear warning for existing-profile access. Forward the selected profile mode through the connector browser runtime, tolerate legacy config modules and old saved configs, and update regression coverage for the new payload shape.

Alessandro committed May 9, 2026 at 16:25 UTC 0a8aaee9acfc693817e2e693d5974ac346d9196e
8 files changed +145 -8
plugins/_a0_connector/api/v1/browser_runtime.py
+30 -3
@@ -6,7 +6,7 @@ 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 "
9 + "For Browser model-use settings, visit Agent Zero WebUI > Browser settings to choose "
10 "Local models only, Warn when using cloud, or Allow."
11 )
12
@@ -24,6 +24,15 @@ def _normalize_requested_backend(value: object) -> str:
24 return ""
25
26
27 +def _normalize_profile_mode(value: object) -> str:
28 + normalized = _string(value).lower().replace("-", "_").replace(" ", "_")
29 + if normalized in {"agent", "clean", "clean_agent", "a0", "dedicated"}:
30 + return "agent"
31 + if normalized in {"existing", "user", "personal", "current"}:
32 + return "existing"
33 + return ""
34 +
35 +
36 def _runtime_label(value: str) -> str:
37 if value == "host_required":
38 return "Bring Your Own Browser"
@@ -59,12 +68,30 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler):
68 mimetype="application/json",
69 )
70 settings["runtime_backend"] = runtime_backend
71 + if "host_browser_profile_mode" in input or "profile_mode" in input:
72 + profile_mode = _normalize_profile_mode(
73 + input.get("host_browser_profile_mode", input.get("profile_mode"))
74 + )
75 + if not profile_mode:
76 + return Response(
77 + response='{"error":"host_browser_profile_mode must be existing or agent"}',
78 + status=400,
79 + mimetype="application/json",
80 + )
81 + settings["host_browser_profile_mode"] = profile_mode
82 + settings["host_browser_profile_mode"] = (
83 + _normalize_profile_mode(settings.get("host_browser_profile_mode")) or "existing"
84 + )
85 self._save_browser_config(project_name, settings)
86
87 + runtime_backend = settings.get("runtime_backend") or "container"
88 + profile_mode = _normalize_profile_mode(settings.get("host_browser_profile_mode")) or "existing"
89 +
90 return {
91 "ok": True,
66 - "runtime_backend": settings["runtime_backend"],
67 - "label": _runtime_label(settings["runtime_backend"]),
92 + "runtime_backend": runtime_backend,
93 + "host_browser_profile_mode": profile_mode,
94 + "label": _runtime_label(runtime_backend),
95 "project_name": project_name,
96 "agent_profile": "",
97 "privacy_notice": _PRIVACY_NOTICE,
plugins/_browser/default_config.yaml
+5
@@ -19,6 +19,11 @@ runtime_backend: "container"
19 # - allow: allow without warning.
20 host_browser_privacy_policy: "enforce_local"
21
22 +# Host-browser profile preference:
23 +# - existing: use the user's authorized existing browser profile when available.
24 +# - agent: use a clean A0-controlled browser profile on the host.
25 +host_browser_profile_mode: "existing"
26 +
27 # Optional _model_config preset used by Browser-owned model helpers.
28 # Empty uses the effective Main Model.
29 model_preset: ""
plugins/_browser/helpers/config.py
+7
@@ -13,8 +13,10 @@ 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 +HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode"
17 RUNTIME_BACKENDS = {"container", "host_required"}
18 HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
19 +HOST_BROWSER_PROFILE_MODES = {"existing", "agent"}
20 BASE_BROWSER_ARGS = [
21 "--no-sandbox",
22 "--disable-dev-shm-usage",
@@ -110,6 +112,11 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
112 allowed=HOST_BROWSER_PRIVACY_POLICIES,
113 default="enforce_local",
114 ),
115 + HOST_BROWSER_PROFILE_MODE_KEY: _normalize_choice(
116 + raw.get(HOST_BROWSER_PROFILE_MODE_KEY, "existing"),
117 + allowed=HOST_BROWSER_PROFILE_MODES,
118 + default="existing",
119 + ),
120 MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
121 }
122
plugins/_browser/helpers/connector_runtime.py
+20 -4
@@ -34,10 +34,7 @@ from plugins._a0_connector.helpers.ws_runtime import (
34 select_host_browser_target_sid,
35 store_pending_browser_op,
36 )
37 -from plugins._browser.helpers.config import (
38 - HOST_BROWSER_PRIVACY_POLICY_KEY,
39 - get_browser_config,
40 -)
37 +from plugins._browser.helpers import config as browser_config
38 from plugins._browser.helpers.url import normalize_url
39
40
@@ -47,6 +44,17 @@ HOST_BROWSER_SCREENSHOT_DIR = ("tmp", "browser", "host-screenshots")
44 CONTENT_HELPER_PATH = Path(__file__).resolve().parents[1] / "assets" / "browser-page-content.js"
45 MAX_ARTIFACT_SIZE_BYTES = 25 * 1024 * 1024
46 BASE64_DECODE_CHARS_PER_CHUNK = 64 * 1024
47 +HOST_BROWSER_PRIVACY_POLICY_KEY = getattr(
48 + browser_config,
49 + "HOST_BROWSER_PRIVACY_POLICY_KEY",
50 + "host_browser_privacy_policy",
51 +)
52 +HOST_BROWSER_PROFILE_MODE_KEY = getattr(
53 + browser_config,
54 + "HOST_BROWSER_PROFILE_MODE_KEY",
55 + "host_browser_profile_mode",
56 +)
57 +get_browser_config = browser_config.get_browser_config
58 _LOCAL_PROVIDERS = {"ollama", "lm_studio"}
59 _LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
60 _SENSITIVE_ACTIONS = {"content", "detail", "evaluate", "screenshot", "screenshot_file"}
@@ -79,6 +87,7 @@ class ConnectorBrowserRuntime:
87 "op_id": str(uuid.uuid4()),
88 "context_id": self.context_id,
89 "action": action,
90 + "profile_mode": self._host_browser_profile_mode(),
91 }
92
93 if action == "open":
@@ -188,6 +197,7 @@ class ConnectorBrowserRuntime:
197 return normalized_calls
198
199 async def _dispatch(self, payload: dict[str, Any]) -> Any:
200 + payload.setdefault("profile_mode", self._host_browser_profile_mode())
201 self._enforce_privacy(payload)
202 sid = self._select_sid()
203 if not sid:
@@ -207,6 +217,7 @@ class ConnectorBrowserRuntime:
217 "op_id": str(uuid.uuid4()),
218 "context_id": self.context_id,
219 "action": "ensure",
220 + "profile_mode": self._host_browser_profile_mode(),
221 },
222 ),
223 )
@@ -214,6 +225,11 @@ class ConnectorBrowserRuntime:
225
226 return await self._send_browser_op(sid, self._with_content_helper(sid, payload))
227
228 + def _host_browser_profile_mode(self) -> str:
229 + config = get_browser_config(self.agent)
230 + mode = str(config.get(HOST_BROWSER_PROFILE_MODE_KEY) or "existing").strip().lower()
231 + return "agent" if mode == "agent" else "existing"
232 +
233 def _with_content_helper(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
234 metadata = host_browser_metadata_for_sid(sid) or {}
235 if str(metadata.get("content_helper_sha256") or "").strip().lower() == _content_helper_sha256():
plugins/_browser/webui/browser-config-store.js
+12
@@ -5,6 +5,7 @@ const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions";
5 const BROWSER_STATUS_API = "/plugins/_browser/status";
6 const RUNTIME_BACKENDS = new Set(["container", "host_required"]);
7 const HOST_PRIVACY_POLICIES = new Set(["enforce_local", "warn", "allow"]);
8 +const HOST_PROFILE_MODES = new Set(["existing", "agent"]);
9
10 function normalizePathList(value) {
11 const source = Array.isArray(value)
@@ -32,6 +33,11 @@ function ensureConfig(config) {
33 HOST_PRIVACY_POLICIES,
34 "enforce_local",
35 );
36 + config.host_browser_profile_mode = normalizeChoice(
37 + config.host_browser_profile_mode,
38 + HOST_PROFILE_MODES,
39 + "existing",
40 + );
41 config.model_preset = String(config.model_preset || "").trim();
42 delete config.model;
43 return config;
@@ -139,6 +145,12 @@ export const store = createStore("browserConfig", {
145 return "Local Models Only";
146 },
147
148 + hostBrowserProfileModeLabel() {
149 + const value = this.config?.host_browser_profile_mode || "existing";
150 + if (value === "agent") return "Clean Agent Profile";
151 + return "Existing Browser Profile";
152 + },
153 +
154 async loadHostBrowserStatus() {
155 if (this.hostBrowserStatusLoading) return;
156 this.hostBrowserStatusLoading = true;
plugins/_browser/webui/config.html
+28
@@ -35,6 +35,34 @@
35 </span>
36 </label>
37
38 + <label
39 + class="browser-config-field"
40 + x-show="$store.browserConfig.config.runtime_backend === 'host_required'"
41 + >
42 + <span class="browser-config-field-label">Host browser profile</span>
43 + <select x-model="$store.browserConfig.config.host_browser_profile_mode">
44 + <option value="existing">Existing browser profile</option>
45 + <option value="agent">Clean Agent profile</option>
46 + </select>
47 + <span
48 + class="browser-config-field-help"
49 + x-show="$store.browserConfig.config.host_browser_profile_mode === 'agent'"
50 + >
51 + Uses a separate A0-controlled local profile on this computer.
52 + </span>
53 + </label>
54 +
55 + <div
56 + class="browser-config-warning"
57 + x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"
58 + >
59 + <span class="material-symbols-outlined">warning</span>
60 + <span>
61 + Existing profile lets the agent interact with the browser instance you authorize,
62 + including signed-in sites, cookies, tabs, downloads, and page content.
63 + </span>
64 + </div>
65 +
66 <label class="browser-config-field">
67 <span class="browser-config-field-label">Page content access</span>
68 <select x-model="$store.browserConfig.config.host_browser_privacy_policy">
tests/test_browser_agent_regressions.py
+3
@@ -155,6 +155,7 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
155 "autofocus_active_page": True,
156 "runtime_backend": "container",
157 "host_browser_privacy_policy": "enforce_local",
158 + "host_browser_profile_mode": "existing",
159 "model_preset": "",
160 }
161
@@ -169,11 +170,13 @@ def test_browser_config_normalizes_host_backend_and_privacy_policy():
170 {
171 "runtime_backend": "host-required",
172 "host_browser_privacy_policy": "warn",
173 + "host_browser_profile_mode": "agent",
174 }
175 )
176
177 assert config["runtime_backend"] == "host_required"
178 assert config["host_browser_privacy_policy"] == "warn"
179 + assert config["host_browser_profile_mode"] == "agent"
180 assert (
181 normalize_browser_config({"runtime_backend": "host_when_available"})["runtime_backend"]
182 == "host_required"
tests/test_host_browser_connector.py
+40 -1
@@ -1,6 +1,7 @@
1 from __future__ import annotations
2
3 import asyncio
4 +import importlib
5 import sys
6 from pathlib import Path
7 from types import SimpleNamespace
@@ -188,6 +189,22 @@ def test_host_browser_privacy_detects_local_model(monkeypatch):
189 assert _agent_uses_local_chat_model(_agent()) is True
190
191
192 +def test_connector_runtime_tolerates_legacy_config_module(monkeypatch):
193 + import plugins._browser.helpers.config as browser_config
194 + import plugins._browser.helpers.connector_runtime as connector_runtime_module
195 +
196 + original = getattr(browser_config, "HOST_BROWSER_PROFILE_MODE_KEY", None)
197 + monkeypatch.delattr(browser_config, "HOST_BROWSER_PROFILE_MODE_KEY", raising=False)
198 +
199 + reloaded = importlib.reload(connector_runtime_module)
200 +
201 + assert reloaded.HOST_BROWSER_PROFILE_MODE_KEY == "host_browser_profile_mode"
202 +
203 + if original is not None:
204 + monkeypatch.setattr(browser_config, "HOST_BROWSER_PROFILE_MODE_KEY", original, raising=False)
205 + importlib.reload(connector_runtime_module)
206 +
207 +
208 def test_host_browser_privacy_blocks_cloud_content(monkeypatch):
209 import plugins._browser.helpers.connector_runtime as connector_runtime_module
210 from plugins._model_config.helpers import model_config
@@ -210,7 +227,14 @@ def test_host_browser_privacy_blocks_cloud_content(monkeypatch):
227 runtime._enforce_privacy({"action": "content"})
228
229
213 -def test_connector_runtime_normalizes_host_navigation_payloads():
230 +def test_connector_runtime_normalizes_host_navigation_payloads(monkeypatch):
231 + import plugins._browser.helpers.connector_runtime as connector_runtime_module
232 +
233 + monkeypatch.setattr(
234 + connector_runtime_module,
235 + "get_browser_config",
236 + lambda agent=None: {"host_browser_profile_mode": "existing"},
237 + )
238 runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
239
240 open_payload = runtime._payload_for_call("open", "localhost:3000")
@@ -236,6 +260,20 @@ def test_connector_runtime_normalizes_host_navigation_payloads():
260 assert multi_payload["calls"][1]["url"] == "http://127.0.0.1:8000/path"
261 assert multi_payload["calls"][2]["calls"][0]["url"] == "https://nested.example/"
262 assert multi_payload["calls"][3] == {"action": "content", "browser_id": 1}
263 + assert open_payload["profile_mode"] == "existing"
264 +
265 +
266 +def test_connector_runtime_forwards_host_profile_mode(monkeypatch):
267 + import plugins._browser.helpers.connector_runtime as connector_runtime_module
268 +
269 + monkeypatch.setattr(
270 + connector_runtime_module,
271 + "get_browser_config",
272 + lambda agent=None: {"host_browser_profile_mode": "agent"},
273 + )
274 + runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
275 +
276 + assert runtime._payload_for_call("open", "example.com")["profile_mode"] == "agent"
277
278
279 def test_host_browser_artifacts_materialize_inside_multi_results(monkeypatch, tmp_path):
@@ -367,6 +405,7 @@ def test_connector_runtime_ensures_preparable_host_browser_before_action(monkeyp
405
406 assert result == {"id": 1, "state": {"runtime": "host"}}
407 assert [payload["action"] for payload in emitted] == ["ensure", "open"]
408 + assert [payload["profile_mode"] for payload in emitted] == ["existing", "existing"]
409 assert "__spaceBrowserPageContent__" in emitted[0]["content_helper"]["source"]
410 assert "capture" in emitted[0]["content_helper"]["required_apis"]
411 assert emitted[0]["content_helper"]["sha256"]