Refine BYOB host browser endpoint selection

Reduce Browser Settings host-browser choices to automatic and advertised debug endpoints, with a validated Custom endpoint input and faster inventory refresh. Update Browser docs and regressions to document the custom endpoint workflow and CLI websocket selection.

Alessandro committed Jul 5, 2026 at 19:59 UTC afa5231a5a6238892a9202ac12192f9bf0e6963b
6 files changed +133 -10
docs/guides/a0-cli-connector.md
+5 -3
@@ -162,8 +162,9 @@ control starts when Agent Zero actually needs to use the browser.
162 > control.
163
164 The **Host browser** list in Browser settings comes from the connected local A0
165 -CLI, not from the Agent Zero Web UI server. If a newly authorized browser does
166 -not appear, restart or reconnect A0 CLI.
165 +CLI, not from the Agent Zero Web UI server. It shows Automatic, currently
166 +advertised debug endpoints, and **Custom endpoint**. If a newly authorized
167 +browser does not appear, restart or reconnect A0 CLI.
168
169 If the inspect checkbox is not enough for your browser build, launch it with an
170 explicit remote debugging port and a separate profile:
@@ -172,7 +173,8 @@ explicit remote debugging port and a separate profile:
173 opera --remote-debugging-port=9222 --user-data-dir="$HOME/.config/a0-opera-debug"
174 ```
175
175 -Then pass the full DevTools websocket endpoint to A0 CLI:
176 +Then choose **Custom endpoint** in Browser settings, run `/browser ws://...` in
177 +A0 CLI, or pass the full DevTools websocket endpoint to A0 CLI:
178
179 ```bash
180 export A0_HOST_BROWSER_REMOTE_DEBUGGING_ENDPOINTS="ws://127.0.0.1:9222/devtools/browser/..."
docs/guides/browser.md
+4 -2
@@ -156,7 +156,8 @@ Remote debugging pages:
156 | Chrome, Edge, Brave, Vivaldi, Chromium | `chrome://inspect/#remote-debugging` |
157 | Opera | `opera://inspect/#remote-debugging` |
158
159 -If a browser does not appear in the **Host browser** list after enabling remote
159 +The **Host browser** list shows Automatic, currently advertised debug endpoints,
160 +and **Custom endpoint**. If a browser does not appear after enabling remote
161 debugging, restart or reconnect the local A0 CLI. Restarting only the Agent Zero
162 Web UI server does not refresh the browser inventory; the list comes from the
163 connected CLI.
@@ -168,7 +169,8 @@ directory:
169 opera --remote-debugging-port=9222 --user-data-dir="$HOME/.config/a0-opera-debug"
170 ```
171
171 -Then pass the full DevTools websocket endpoint to the CLI:
172 +Then choose **Custom endpoint** in Browser settings, or pass the full DevTools
173 +websocket endpoint to the CLI:
174
175 ```bash
176 export A0_HOST_BROWSER_REMOTE_DEBUGGING_ENDPOINTS="ws://127.0.0.1:9222/devtools/browser/..."
plugins/_browser/AGENTS.md
+1
@@ -26,6 +26,7 @@
26 - Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
27 - 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`.
28 - Browser Settings must refresh connected A0 CLI host-browser inventory while the settings view is open so newly authorized endpoints appear without saving or reopening.
29 +- Browser Settings keeps the Host browser dropdown focused on automatic selection, advertised debug endpoints, and a validated Custom endpoint field instead of listing every installed local profile.
30 - Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
31 - Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
32 - Do not hardcode user-specific browser paths or secrets.
plugins/_browser/webui/browser-config-store.js
+97 -3
@@ -10,7 +10,8 @@ const HOST_PROFILE_MODES = new Set(["existing", "agent"]);
10 const DEFAULT_MAX_OPEN_TABS = 32;
11 const MIN_MAX_OPEN_TABS = 1;
12 const HARD_MAX_OPEN_TABS = 50;
13 -const HOST_BROWSER_STATUS_REFRESH_MS = 3000;
13 +const HOST_BROWSER_STATUS_REFRESH_MS = 1000;
14 +const CUSTOM_HOST_BROWSER_SELECTION = "__custom_endpoint__";
15
16 function normalizePathList(value) {
17 const source = Array.isArray(value)
@@ -72,6 +73,39 @@ function normalizeHostBrowserSelection(value) {
73 return String(value || "").trim().toLowerCase().replace(/\s+/g, "_").slice(0, 200);
74 }
75
76 +function normalizeCustomHostBrowserEndpoint(value) {
77 + const raw = String(value || "").trim();
78 + if (!raw) return "";
79 + const candidate = raw.includes("://") ? raw : `ws://${raw}`;
80 + try {
81 + const url = new URL(candidate);
82 + if (!["ws:", "wss:"].includes(url.protocol) || !url.host || !url.pathname.startsWith("/devtools/browser/")) {
83 + return "";
84 + }
85 + return normalizeHostBrowserSelection(`${url.protocol}//${url.host}${url.pathname}${url.search || ""}`);
86 + } catch (_error) {
87 + return "";
88 + }
89 +}
90 +
91 +function isCustomHostBrowserEndpoint(value) {
92 + return Boolean(normalizeCustomHostBrowserEndpoint(value));
93 +}
94 +
95 +function debugPortVersionUrl(value) {
96 + const raw = String(value || "").trim();
97 + if (!raw) return "";
98 + const candidate = raw.includes("://") ? raw : `ws://${raw}`;
99 + try {
100 + const url = new URL(candidate);
101 + if (!["ws:", "wss:"].includes(url.protocol) || !url.host) return "";
102 + if (url.pathname && url.pathname !== "/") return "";
103 + return `http://${url.host}/json/version`;
104 + } catch (_error) {
105 + return "";
106 + }
107 +}
108 +
109 function normalizeBoolean(value, fallback = true) {
110 if (value === undefined || value === null || value === "") return fallback;
111 if (typeof value === "boolean") return value;
@@ -121,6 +155,8 @@ export const store = createStore("browserConfig", {
155 hostBrowserStatus: null,
156 hostBrowserStatusLoading: false,
157 hostBrowserStatusRefreshTimer: null,
158 + hostBrowserCustomEndpoint: "",
159 + hostBrowserCustomMode: false,
160
161 async init(config) {
162 this.bindConfig(config);
@@ -137,6 +173,8 @@ export const store = createStore("browserConfig", {
173 this.extensionDeleteLoadingPath = "";
174 this.hostBrowserStatus = null;
175 this.hostBrowserStatusLoading = false;
176 + this.hostBrowserCustomEndpoint = "";
177 + this.hostBrowserCustomMode = false;
178 },
179
180 startHostBrowserStatusRefresh() {
@@ -158,6 +196,9 @@ export const store = createStore("browserConfig", {
196 if (!safeConfig) return;
197 if (this.config === safeConfig) return;
198 this.config = safeConfig;
199 + if (isCustomHostBrowserEndpoint(safeConfig.host_browser_selection)) {
200 + this.hostBrowserCustomEndpoint = safeConfig.host_browser_selection;
201 + }
202 },
203
204 setAutofocusActivePage(enabled) {
@@ -215,29 +256,82 @@ export const store = createStore("browserConfig", {
256 ? connector.available_browsers
257 : [];
258 for (const browser of advertised) {
218 - const value = normalizeHostBrowserSelection(browser?.id || browser?.family || browser?.cdp_endpoint);
259 + const value = normalizeCustomHostBrowserEndpoint(browser?.cdp_endpoint || browser?.id);
260 if (!value || seen.has(value)) continue;
261 seen.add(value);
262 const label = browser?.label || hostBrowserFamilyLabel(browser?.family || value);
263 const status = browser?.status ? ` - ${hostBrowserStatusLabel(browser.status)}` : "";
264 options.push({ value, label: `${label}${status}` });
265 }
225 - const fallbackValue = normalizeHostBrowserSelection(connector?.browser_id || connector?.browser_family);
266 + const fallbackValue = normalizeCustomHostBrowserEndpoint(connector?.cdp_endpoint || connector?.browser_id);
267 if (fallbackValue && !seen.has(fallbackValue)) {
268 seen.add(fallbackValue);
269 const label = connector?.browser_label || hostBrowserFamilyLabel(connector?.browser_family || fallbackValue);
270 options.push({ value: fallbackValue, label });
271 }
272 }
273 + const selected = normalizeHostBrowserSelection(this.config?.host_browser_selection);
274 + if (selected && !seen.has(selected) && !isCustomHostBrowserEndpoint(selected)) {
275 + seen.add(selected);
276 + options.push({ value: selected, label: `Saved: ${selected}` });
277 + }
278 + options.push({ value: CUSTOM_HOST_BROWSER_SELECTION, label: "Custom endpoint" });
279 return options;
280 },
281
282 + hostBrowserSelectValue() {
283 + if (this.hostBrowserCustomMode) return CUSTOM_HOST_BROWSER_SELECTION;
284 + const selected = normalizeHostBrowserSelection(this.config?.host_browser_selection);
285 + if (!selected) return "";
286 + if (this.hostBrowserOptions().some((option) => option.value === selected)) return selected;
287 + if (isCustomHostBrowserEndpoint(selected)) return CUSTOM_HOST_BROWSER_SELECTION;
288 + return selected;
289 + },
290 +
291 setHostBrowserSelection(value) {
292 const safeConfig = ensureConfig(this.config);
293 if (!safeConfig) return;
294 + if (value === CUSTOM_HOST_BROWSER_SELECTION) {
295 + this.hostBrowserCustomMode = true;
296 + if (isCustomHostBrowserEndpoint(safeConfig.host_browser_selection)) {
297 + this.hostBrowserCustomEndpoint = safeConfig.host_browser_selection;
298 + } else {
299 + safeConfig.host_browser_selection = "";
300 + }
301 + return;
302 + }
303 + this.hostBrowserCustomMode = false;
304 safeConfig.host_browser_selection = normalizeHostBrowserSelection(value);
305 },
306
307 + showCustomHostBrowserEndpoint() {
308 + return this.hostBrowserSelectValue() === CUSTOM_HOST_BROWSER_SELECTION;
309 + },
310 +
311 + setCustomHostBrowserEndpoint(value) {
312 + this.hostBrowserCustomMode = true;
313 + this.hostBrowserCustomEndpoint = String(value || "").trim();
314 + const safeConfig = ensureConfig(this.config);
315 + if (!safeConfig) return;
316 + const endpoint = normalizeCustomHostBrowserEndpoint(this.hostBrowserCustomEndpoint);
317 + if (endpoint || !this.hostBrowserCustomEndpoint) {
318 + safeConfig.host_browser_selection = endpoint;
319 + }
320 + },
321 +
322 + customHostBrowserEndpointDiagnostic() {
323 + if (!this.hostBrowserCustomEndpoint) {
324 + return "Paste a ws://.../devtools/browser/... endpoint from the browser inspect page.";
325 + }
326 + const endpoint = normalizeCustomHostBrowserEndpoint(this.hostBrowserCustomEndpoint);
327 + if (endpoint) return `Using ${endpoint}`;
328 + const versionUrl = debugPortVersionUrl(this.hostBrowserCustomEndpoint);
329 + if (versionUrl) {
330 + return `This looks like a debug port. Open ${versionUrl} and copy webSocketDebuggerUrl.`;
331 + }
332 + return "Endpoint must be a ws:// or wss:// URL ending in /devtools/browser/...";
333 + },
334 +
335 hostBrowserProfileModeLabel() {
336 const value = this.config?.host_browser_profile_mode || "existing";
337 if (value === "agent") return "Clean Agent Profile";
plugins/_browser/webui/config.html
+20 -2
@@ -72,14 +72,32 @@
72 >
73 <span class="browser-config-field-label">Host browser</span>
74 <select
75 - :value="$store.browserConfig.config.host_browser_selection || ''"
75 + :value="$store.browserConfig.hostBrowserSelectValue()"
76 + @focus="$store.browserConfig.loadHostBrowserStatus()"
77 + @click="$store.browserConfig.loadHostBrowserStatus()"
78 @change="$store.browserConfig.setHostBrowserSelection($event.target.value)"
79 >
80 <template x-for="option in $store.browserConfig.hostBrowserOptions()" :key="option.value">
81 <option :value="option.value" x-text="option.label"></option>
82 </template>
83 </select>
82 - <span class="browser-config-field-help">Choose which installed browser/debug endpoint A0 CLI should use when multiple are available.</span>
84 + <span class="browser-config-field-help">Choose an allowed debug endpoint, or leave Automatic so A0 CLI picks.</span>
85 + </label>
86 +
87 + <label
88 + class="browser-config-field"
89 + x-show="$store.browserConfig.config.runtime_backend === 'host_required' && $store.browserConfig.config.host_browser_profile_mode !== 'agent' && $store.browserConfig.showCustomHostBrowserEndpoint()"
90 + >
91 + <span class="browser-config-field-label">Custom endpoint</span>
92 + <input
93 + type="text"
94 + :value="$store.browserConfig.hostBrowserCustomEndpoint"
95 + @focus="$store.browserConfig.loadHostBrowserStatus()"
96 + @input="$store.browserConfig.setCustomHostBrowserEndpoint($event.target.value)"
97 + placeholder="ws://127.0.0.1:9222/devtools/browser/..."
98 + autocomplete="off"
99 + />
100 + <span class="browser-config-field-help" x-text="$store.browserConfig.customHostBrowserEndpointDiagnostic()"></span>
101 </label>
102
103 <div
tests/test_browser_agent_regressions.py
+6
@@ -981,6 +981,9 @@ def test_browser_tool_does_not_auto_open_canvas_policy_is_documented():
981 config_html = (PROJECT_ROOT / "plugins" / "_browser" / "webui" / "config.html").read_text(
982 encoding="utf-8"
983 )
984 + config_store_js = (
985 + PROJECT_ROOT / "plugins" / "_browser" / "webui" / "browser-config-store.js"
986 + ).read_text(encoding="utf-8")
987
988 assert "must not open a Browser surface automatically" in prompt
989 assert "Use the tool headlessly unless the user opens the Browser surface" in prompt
@@ -1002,6 +1005,9 @@ def test_browser_tool_does_not_auto_open_canvas_policy_is_documented():
1005 assert "chrome://inspect/#remote-debugging" in config_html
1006 assert "opera://inspect/#remote-debugging" in config_html
1007 assert "A0_HOST_BROWSER_REMOTE_DEBUGGING_ENDPOINTS" in config_html
1008 + assert "Custom endpoint" in config_html
1009 + assert "customHostBrowserEndpointDiagnostic" in config_store_js
1010 + assert "HOST_BROWSER_STATUS_REFRESH_MS = 1000" in config_store_js
1011
1012
1013 def test_browser_skills_are_plugin_owned_and_progressively_linked():