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.
This commit is contained in:
Alessandro 2026-07-04 18:33:58 +02:00
parent d018177927
commit 7298a88fda
10 changed files with 158 additions and 0 deletions

View file

@ -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

View file

@ -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": "",

View file

@ -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,

View file

@ -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.

View file

@ -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: ""

View file

@ -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, "")),
}

View file

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

View file

@ -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";

View file

@ -66,6 +66,22 @@
</span>
</label>
<label
class="browser-config-field"
x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"
>
<span class="browser-config-field-label">Host browser</span>
<select
:value="$store.browserConfig.config.host_browser_selection || ''"
@change="$store.browserConfig.setHostBrowserSelection($event.target.value)"
>
<template x-for="option in $store.browserConfig.hostBrowserOptions()" :key="option.value">
<option :value="option.value" x-text="option.label"></option>
</template>
</select>
<span class="browser-config-field-help">Choose which installed browser/debug endpoint A0 CLI should use when multiple are available.</span>
</label>
<div
class="browser-config-warning"
x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent'"

View file

@ -162,6 +162,7 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
"runtime_backend": "container",
"host_browser_privacy_policy": "allow",
"host_browser_profile_mode": "existing",
"host_browser_selection": "",
"model_preset": "",
}
@ -183,6 +184,27 @@ def test_browser_config_normalizes_host_backend_and_privacy_policy():
assert config["runtime_backend"] == "host_required"
assert config["host_browser_privacy_policy"] == "warn"
assert config["host_browser_profile_mode"] == "agent"
def test_browser_config_normalizes_host_browser_selection():
assert normalize_browser_config({})["host_browser_selection"] == ""
assert (
normalize_browser_config({"host_browser_selection": " Edge Dev "})["host_browser_selection"]
== "edge_dev"
)
assert (
normalize_browser_config({"host_browser_choice": "chrome"})["host_browser_selection"]
== "chrome"
)
assert (
normalize_browser_config({"host_browser_selection": "ws://127.0.0.1:9222/devtools"})[
"host_browser_selection"
]
== "ws://127.0.0.1:9222/devtools"
)
assert normalize_browser_config({"host_browser_selection": "bad\x00value"})[
"host_browser_selection"
] == "badvalue"
assert (
normalize_browser_config({"runtime_backend": "host_when_available"})["runtime_backend"]
== "host_required"