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():