mirror of
https://github.com/agent0ai/agent-zero.git
synced 2026-07-09 17:08:29 +00:00
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:
parent
d018177927
commit
7298a88fda
10 changed files with 158 additions and 0 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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: ""
|
||||
|
|
|
|||
|
|
@ -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, "")),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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'"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue