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.
This commit is contained in:
Alessandro 2026-05-09 16:25:27 +02:00
parent 02838f7698
commit 0a8aaee9ac
8 changed files with 145 additions and 8 deletions

View file

@ -6,7 +6,7 @@ import plugins._a0_connector.api.v1.base as connector_base
_PRIVACY_NOTICE = (
"For GDPR/content policy, visit Agent Zero WebUI > Browser settings to choose "
"For Browser model-use settings, visit Agent Zero WebUI > Browser settings to choose "
"Local models only, Warn when using cloud, or Allow."
)
@ -24,6 +24,15 @@ def _normalize_requested_backend(value: object) -> str:
return ""
def _normalize_profile_mode(value: object) -> str:
normalized = _string(value).lower().replace("-", "_").replace(" ", "_")
if normalized in {"agent", "clean", "clean_agent", "a0", "dedicated"}:
return "agent"
if normalized in {"existing", "user", "personal", "current"}:
return "existing"
return ""
def _runtime_label(value: str) -> str:
if value == "host_required":
return "Bring Your Own Browser"
@ -59,12 +68,30 @@ class BrowserRuntime(connector_base.ProtectedConnectorApiHandler):
mimetype="application/json",
)
settings["runtime_backend"] = runtime_backend
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"))
)
if not profile_mode:
return Response(
response='{"error":"host_browser_profile_mode must be existing or agent"}',
status=400,
mimetype="application/json",
)
settings["host_browser_profile_mode"] = profile_mode
settings["host_browser_profile_mode"] = (
_normalize_profile_mode(settings.get("host_browser_profile_mode")) or "existing"
)
self._save_browser_config(project_name, settings)
runtime_backend = settings.get("runtime_backend") or "container"
profile_mode = _normalize_profile_mode(settings.get("host_browser_profile_mode")) or "existing"
return {
"ok": True,
"runtime_backend": settings["runtime_backend"],
"label": _runtime_label(settings["runtime_backend"]),
"runtime_backend": runtime_backend,
"host_browser_profile_mode": profile_mode,
"label": _runtime_label(runtime_backend),
"project_name": project_name,
"agent_profile": "",
"privacy_notice": _PRIVACY_NOTICE,

View file

@ -19,6 +19,11 @@ runtime_backend: "container"
# - allow: allow without warning.
host_browser_privacy_policy: "enforce_local"
# Host-browser profile preference:
# - existing: use the user's authorized existing browser profile when available.
# - agent: use a clean A0-controlled browser profile on the host.
host_browser_profile_mode: "existing"
# Optional _model_config preset used by Browser-owned model helpers.
# Empty uses the effective Main Model.
model_preset: ""

View file

@ -13,8 +13,10 @@ DEFAULT_HOMEPAGE_KEY = "default_homepage"
AUTOFOCUS_ACTIVE_PAGE_KEY = "autofocus_active_page"
RUNTIME_BACKEND_KEY = "runtime_backend"
HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy"
HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode"
RUNTIME_BACKENDS = {"container", "host_required"}
HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
HOST_BROWSER_PROFILE_MODES = {"existing", "agent"}
BASE_BROWSER_ARGS = [
"--no-sandbox",
"--disable-dev-shm-usage",
@ -110,6 +112,11 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
allowed=HOST_BROWSER_PRIVACY_POLICIES,
default="enforce_local",
),
HOST_BROWSER_PROFILE_MODE_KEY: _normalize_choice(
raw.get(HOST_BROWSER_PROFILE_MODE_KEY, "existing"),
allowed=HOST_BROWSER_PROFILE_MODES,
default="existing",
),
MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
}

View file

@ -34,10 +34,7 @@ from plugins._a0_connector.helpers.ws_runtime import (
select_host_browser_target_sid,
store_pending_browser_op,
)
from plugins._browser.helpers.config import (
HOST_BROWSER_PRIVACY_POLICY_KEY,
get_browser_config,
)
from plugins._browser.helpers import config as browser_config
from plugins._browser.helpers.url import normalize_url
@ -47,6 +44,17 @@ HOST_BROWSER_SCREENSHOT_DIR = ("tmp", "browser", "host-screenshots")
CONTENT_HELPER_PATH = Path(__file__).resolve().parents[1] / "assets" / "browser-page-content.js"
MAX_ARTIFACT_SIZE_BYTES = 25 * 1024 * 1024
BASE64_DECODE_CHARS_PER_CHUNK = 64 * 1024
HOST_BROWSER_PRIVACY_POLICY_KEY = getattr(
browser_config,
"HOST_BROWSER_PRIVACY_POLICY_KEY",
"host_browser_privacy_policy",
)
HOST_BROWSER_PROFILE_MODE_KEY = getattr(
browser_config,
"HOST_BROWSER_PROFILE_MODE_KEY",
"host_browser_profile_mode",
)
get_browser_config = browser_config.get_browser_config
_LOCAL_PROVIDERS = {"ollama", "lm_studio"}
_LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1", "host.docker.internal"}
_SENSITIVE_ACTIONS = {"content", "detail", "evaluate", "screenshot", "screenshot_file"}
@ -79,6 +87,7 @@ class ConnectorBrowserRuntime:
"op_id": str(uuid.uuid4()),
"context_id": self.context_id,
"action": action,
"profile_mode": self._host_browser_profile_mode(),
}
if action == "open":
@ -188,6 +197,7 @@ class ConnectorBrowserRuntime:
return normalized_calls
async def _dispatch(self, payload: dict[str, Any]) -> Any:
payload.setdefault("profile_mode", self._host_browser_profile_mode())
self._enforce_privacy(payload)
sid = self._select_sid()
if not sid:
@ -207,6 +217,7 @@ class ConnectorBrowserRuntime:
"op_id": str(uuid.uuid4()),
"context_id": self.context_id,
"action": "ensure",
"profile_mode": self._host_browser_profile_mode(),
},
),
)
@ -214,6 +225,11 @@ class ConnectorBrowserRuntime:
return await self._send_browser_op(sid, self._with_content_helper(sid, payload))
def _host_browser_profile_mode(self) -> str:
config = get_browser_config(self.agent)
mode = str(config.get(HOST_BROWSER_PROFILE_MODE_KEY) or "existing").strip().lower()
return "agent" if mode == "agent" else "existing"
def _with_content_helper(self, sid: str, payload: dict[str, Any]) -> dict[str, Any]:
metadata = host_browser_metadata_for_sid(sid) or {}
if str(metadata.get("content_helper_sha256") or "").strip().lower() == _content_helper_sha256():

View file

@ -5,6 +5,7 @@ const BROWSER_EXTENSIONS_API = "/plugins/_browser/extensions";
const BROWSER_STATUS_API = "/plugins/_browser/status";
const RUNTIME_BACKENDS = new Set(["container", "host_required"]);
const HOST_PRIVACY_POLICIES = new Set(["enforce_local", "warn", "allow"]);
const HOST_PROFILE_MODES = new Set(["existing", "agent"]);
function normalizePathList(value) {
const source = Array.isArray(value)
@ -32,6 +33,11 @@ function ensureConfig(config) {
HOST_PRIVACY_POLICIES,
"enforce_local",
);
config.host_browser_profile_mode = normalizeChoice(
config.host_browser_profile_mode,
HOST_PROFILE_MODES,
"existing",
);
config.model_preset = String(config.model_preset || "").trim();
delete config.model;
return config;
@ -139,6 +145,12 @@ export const store = createStore("browserConfig", {
return "Local Models Only";
},
hostBrowserProfileModeLabel() {
const value = this.config?.host_browser_profile_mode || "existing";
if (value === "agent") return "Clean Agent Profile";
return "Existing Browser Profile";
},
async loadHostBrowserStatus() {
if (this.hostBrowserStatusLoading) return;
this.hostBrowserStatusLoading = true;

View file

@ -35,6 +35,34 @@
</span>
</label>
<label
class="browser-config-field"
x-show="$store.browserConfig.config.runtime_backend === 'host_required'"
>
<span class="browser-config-field-label">Host browser profile</span>
<select x-model="$store.browserConfig.config.host_browser_profile_mode">
<option value="existing">Existing browser profile</option>
<option value="agent">Clean Agent profile</option>
</select>
<span
class="browser-config-field-help"
x-show="$store.browserConfig.config.host_browser_profile_mode === 'agent'"
>
Uses a separate A0-controlled local profile on this computer.
</span>
</label>
<div
class="browser-config-warning"
x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"
>
<span class="material-symbols-outlined">warning</span>
<span>
Existing profile lets the agent interact with the browser instance you authorize,
including signed-in sites, cookies, tabs, downloads, and page content.
</span>
</div>
<label class="browser-config-field">
<span class="browser-config-field-label">Page content access</span>
<select x-model="$store.browserConfig.config.host_browser_privacy_policy">

View file

@ -155,6 +155,7 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
"autofocus_active_page": True,
"runtime_backend": "container",
"host_browser_privacy_policy": "enforce_local",
"host_browser_profile_mode": "existing",
"model_preset": "",
}
@ -169,11 +170,13 @@ def test_browser_config_normalizes_host_backend_and_privacy_policy():
{
"runtime_backend": "host-required",
"host_browser_privacy_policy": "warn",
"host_browser_profile_mode": "agent",
}
)
assert config["runtime_backend"] == "host_required"
assert config["host_browser_privacy_policy"] == "warn"
assert config["host_browser_profile_mode"] == "agent"
assert (
normalize_browser_config({"runtime_backend": "host_when_available"})["runtime_backend"]
== "host_required"

View file

@ -1,6 +1,7 @@
from __future__ import annotations
import asyncio
import importlib
import sys
from pathlib import Path
from types import SimpleNamespace
@ -188,6 +189,22 @@ def test_host_browser_privacy_detects_local_model(monkeypatch):
assert _agent_uses_local_chat_model(_agent()) is True
def test_connector_runtime_tolerates_legacy_config_module(monkeypatch):
import plugins._browser.helpers.config as browser_config
import plugins._browser.helpers.connector_runtime as connector_runtime_module
original = getattr(browser_config, "HOST_BROWSER_PROFILE_MODE_KEY", None)
monkeypatch.delattr(browser_config, "HOST_BROWSER_PROFILE_MODE_KEY", raising=False)
reloaded = importlib.reload(connector_runtime_module)
assert reloaded.HOST_BROWSER_PROFILE_MODE_KEY == "host_browser_profile_mode"
if original is not None:
monkeypatch.setattr(browser_config, "HOST_BROWSER_PROFILE_MODE_KEY", original, raising=False)
importlib.reload(connector_runtime_module)
def test_host_browser_privacy_blocks_cloud_content(monkeypatch):
import plugins._browser.helpers.connector_runtime as connector_runtime_module
from plugins._model_config.helpers import model_config
@ -210,7 +227,14 @@ def test_host_browser_privacy_blocks_cloud_content(monkeypatch):
runtime._enforce_privacy({"action": "content"})
def test_connector_runtime_normalizes_host_navigation_payloads():
def test_connector_runtime_normalizes_host_navigation_payloads(monkeypatch):
import plugins._browser.helpers.connector_runtime as connector_runtime_module
monkeypatch.setattr(
connector_runtime_module,
"get_browser_config",
lambda agent=None: {"host_browser_profile_mode": "existing"},
)
runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
open_payload = runtime._payload_for_call("open", "localhost:3000")
@ -236,6 +260,20 @@ def test_connector_runtime_normalizes_host_navigation_payloads():
assert multi_payload["calls"][1]["url"] == "http://127.0.0.1:8000/path"
assert multi_payload["calls"][2]["calls"][0]["url"] == "https://nested.example/"
assert multi_payload["calls"][3] == {"action": "content", "browser_id": 1}
assert open_payload["profile_mode"] == "existing"
def test_connector_runtime_forwards_host_profile_mode(monkeypatch):
import plugins._browser.helpers.connector_runtime as connector_runtime_module
monkeypatch.setattr(
connector_runtime_module,
"get_browser_config",
lambda agent=None: {"host_browser_profile_mode": "agent"},
)
runtime = ConnectorBrowserRuntime("ctx-host", _agent("ctx-host"))
assert runtime._payload_for_call("open", "example.com")["profile_mode"] == "agent"
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
assert result == {"id": 1, "state": {"runtime": "host"}}
assert [payload["action"] for payload in emitted] == ["ensure", "open"]
assert [payload["profile_mode"] for payload in emitted] == ["existing", "existing"]
assert "__spaceBrowserPageContent__" in emitted[0]["content_helper"]["source"]
assert "capture" in emitted[0]["content_helper"]["required_apis"]
assert emitted[0]["content_helper"]["sha256"]