Add proxy support to internal Browser

Add project-scoped HTTP and SOCKS proxy settings for the internal Docker browser, including bypass and optional authentication. Pass them through Playwright's native proxy option, restart active Browser runtimes when they change, and update regression coverage and user documentation.

Alessandro committed Jul 27, 2026 at 12:19 UTC eae1b67c09fd97a3b5939f5b627e37c9c902ef26
9 files changed +133 -6
docs/guides/browser.md
+6
@@ -119,6 +119,7 @@ The toolbar menu includes:
119 The full settings include:
120
121 - **Browser location:** Use the Docker browser or **Bring Your Own Browser** through A0 CLI.
122 +- **Proxy:** Optionally route the Docker browser through an HTTP or SOCKS proxy, with bypass and authentication settings.
123 - **Page content access:** Controls host-browser page text and screenshots.
124 - **Starting page:** The default URL for new Browser sessions.
125 - **Autofocus active page:** Lets an already-open Browser surface follow the agent's browsing.
@@ -135,6 +136,11 @@ can show in the Canvas.
136 In normal Docker installs, the needed browser is already included. In local
137 development, Agent Zero can install it the first time it is needed.
138
139 +To use a proxy, enter its server in Browser settings, for example
140 +`http://proxy.example:3128` or `socks5://proxy.example:1080`. Add an optional
141 +comma-separated bypass list, username, and password when the proxy requires
142 +them. Saving proxy changes restarts active Docker Browser sessions.
143 +
144 ## Bring Your Own Browser
145
146 Bring Your Own Browser lets Agent Zero use Chrome, Edge, Brave, Opera, Vivaldi,
docs/res/usage/browser/browser-plugin-settings.png
Binary files a/docs/res/usage/browser/browser-plugin-settings.png and b/docs/res/usage/browser/browser-plugin-settings.png differ
plugins/_browser/AGENTS.md
+1
@@ -31,6 +31,7 @@
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.
33 - Browser model-preset selection resolves omitted preset fields from `_model_config`'s global `Default` preset, not from an unrelated currently scoped model selection.
34 +- Internal-browser proxy settings map directly to Playwright's persistent-context proxy option, never to Bring Your Own Browser, and changes must restart active internal runtimes.
35
36 ## Work Guidance
37
plugins/_browser/default_config.yaml
+7
@@ -22,6 +22,13 @@ max_open_tabs: 32
22 # - host_required: Bring Your Own Browser through A0 CLI and prepare it on first browser use when possible.
23 runtime_backend: "container"
24
25 +# Optional proxy used only by the internal Docker browser.
26 +# Server examples: http://proxy.example:3128 or socks5://proxy.example:1080.
27 +proxy_server: ""
28 +proxy_bypass: ""
29 +proxy_username: ""
30 +proxy_password: ""
31 +
32 # Local-model enforcement for host-browser content:
33 # - enforce_local: block host page content and screenshots unless the active chat model is local.
34 # - warn: allow and return a warning in tool output.
plugins/_browser/helpers/config.py
+26 -1
@@ -17,6 +17,10 @@ RUNTIME_BACKEND_KEY = "runtime_backend"
17 HOST_BROWSER_PRIVACY_POLICY_KEY = "host_browser_privacy_policy"
18 HOST_BROWSER_PROFILE_MODE_KEY = "host_browser_profile_mode"
19 HOST_BROWSER_SELECTION_KEY = "host_browser_selection"
20 +PROXY_SERVER_KEY = "proxy_server"
21 +PROXY_BYPASS_KEY = "proxy_bypass"
22 +PROXY_USERNAME_KEY = "proxy_username"
23 +PROXY_PASSWORD_KEY = "proxy_password"
24 RUNTIME_BACKENDS = {"container", "host_required"}
25 BROWSER_TAB_SCOPES = {"per_context", "shared"}
26 HOST_BROWSER_PRIVACY_POLICIES = {"enforce_local", "warn", "allow"}
@@ -161,6 +165,10 @@ def normalize_browser_config(settings: dict[str, Any] | None) -> dict[str, Any]:
165 HOST_BROWSER_SELECTION_KEY: _normalize_host_browser_selection(
166 raw.get(HOST_BROWSER_SELECTION_KEY, raw.get("host_browser_choice", ""))
167 ),
168 + PROXY_SERVER_KEY: str(raw.get(PROXY_SERVER_KEY, "") or "").strip()[:2048],
169 + PROXY_BYPASS_KEY: str(raw.get(PROXY_BYPASS_KEY, "") or "").strip()[:4096],
170 + PROXY_USERNAME_KEY: str(raw.get(PROXY_USERNAME_KEY, "") or "")[:1024],
171 + PROXY_PASSWORD_KEY: str(raw.get(PROXY_PASSWORD_KEY, "") or "")[:4096],
172 MODEL_PRESET_KEY: _normalize_model_preset(raw.get(MODEL_PRESET_KEY, "")),
173 }
174
@@ -169,6 +177,10 @@ def browser_runtime_config(settings: dict[str, Any] | None) -> dict[str, Any]:
177 config = normalize_browser_config(settings)
178 return {
179 "extension_paths": config["extension_paths"],
180 + PROXY_SERVER_KEY: config[PROXY_SERVER_KEY],
181 + PROXY_BYPASS_KEY: config[PROXY_BYPASS_KEY],
182 + PROXY_USERNAME_KEY: config[PROXY_USERNAME_KEY],
183 + PROXY_PASSWORD_KEY: config[PROXY_PASSWORD_KEY],
184 }
185
186
@@ -374,10 +386,12 @@ def describe_browser_extensions(settings: dict[str, Any] | None) -> dict[str, An
386
387
388 def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, Any]:
377 - extensions = describe_browser_extensions(settings)
389 + config = normalize_browser_config(settings)
390 + extensions = describe_browser_extensions(config)
391 args = list(BASE_BROWSER_ARGS)
392 channel: str | None = None
393 browser_mode = "chromium"
394 + proxy = None
395
396 if extensions["active"]:
397 joined_paths = ",".join(extensions["active_paths"])
@@ -388,8 +402,19 @@ def build_browser_launch_config(settings: dict[str, Any] | None) -> dict[str, An
402 ]
403 )
404
405 + if config[PROXY_SERVER_KEY]:
406 + proxy = {"server": config[PROXY_SERVER_KEY]}
407 + for config_key, proxy_key in (
408 + (PROXY_BYPASS_KEY, "bypass"),
409 + (PROXY_USERNAME_KEY, "username"),
410 + (PROXY_PASSWORD_KEY, "password"),
411 + ):
412 + if config[config_key]:
413 + proxy[proxy_key] = config[config_key]
414 +
415 return {
416 "args": args,
417 + "proxy": proxy,
418 "browser_mode": browser_mode,
419 "channel": channel,
420 "extensions": extensions,
plugins/_browser/helpers/runtime.py
+2
@@ -822,6 +822,8 @@ class _BrowserRuntimeCore:
822 launch_kwargs["channel"] = launch_config["channel"]
823 else:
824 launch_kwargs["executable_path"] = str(browser_binary)
825 + if launch_config["proxy"]:
826 + launch_kwargs["proxy"] = launch_config["proxy"]
827 try:
828 self.context = await self.playwright.chromium.launch_persistent_context(
829 **launch_kwargs
plugins/_browser/webui/browser-config-store.js
+4
@@ -37,6 +37,10 @@ function ensureConfig(config) {
37 config.browser_tab_scope = normalizeChoice(config.browser_tab_scope, BROWSER_TAB_SCOPES, "per_context");
38 config.max_open_tabs = normalizeInt(config.max_open_tabs, DEFAULT_MAX_OPEN_TABS, MIN_MAX_OPEN_TABS, HARD_MAX_OPEN_TABS);
39 config.runtime_backend = normalizeRuntimeBackend(config.runtime_backend);
40 + config.proxy_server = String(config.proxy_server || "").trim();
41 + config.proxy_bypass = String(config.proxy_bypass || "").trim();
42 + config.proxy_username = String(config.proxy_username || "");
43 + config.proxy_password = String(config.proxy_password || "");
44 config.host_browser_privacy_policy = normalizeChoice(
45 config.host_browser_privacy_policy,
46 HOST_PRIVACY_POLICIES,
plugins/_browser/webui/config.html
+54
@@ -49,6 +49,59 @@
49 <span>Internal Docker browser selected. Host-browser CLI settings are ignored for Browser tool runs.</span>
50 </div>
51
52 + <label
53 + class="browser-config-field"
54 + x-show="$store.browserConfig.config.runtime_backend === 'container'"
55 + >
56 + <span class="browser-config-field-label">Proxy server</span>
57 + <input
58 + type="text"
59 + x-model="$store.browserConfig.config.proxy_server"
60 + placeholder="http://proxy.example:3128 or socks5://proxy.example:1080"
61 + autocomplete="off"
62 + />
63 + <span class="browser-config-field-help">Optional. Routes the internal browser through this HTTP or SOCKS proxy.</span>
64 + </label>
65 +
66 + <label
67 + class="browser-config-field"
68 + x-show="$store.browserConfig.config.runtime_backend === 'container'"
69 + >
70 + <span class="browser-config-field-label">Bypass proxy for</span>
71 + <input
72 + type="text"
73 + x-model="$store.browserConfig.config.proxy_bypass"
74 + placeholder="localhost, .example.com"
75 + autocomplete="off"
76 + />
77 + <span class="browser-config-field-help">Optional comma-separated domains that should connect directly.</span>
78 + </label>
79 +
80 + <label
81 + class="browser-config-field"
82 + x-show="$store.browserConfig.config.runtime_backend === 'container'"
83 + >
84 + <span class="browser-config-field-label">Proxy username</span>
85 + <input
86 + type="text"
87 + x-model="$store.browserConfig.config.proxy_username"
88 + autocomplete="off"
89 + />
90 + </label>
91 +
92 + <label
93 + class="browser-config-field"
94 + x-show="$store.browserConfig.config.runtime_backend === 'container'"
95 + >
96 + <span class="browser-config-field-label">Proxy password</span>
97 + <input
98 + type="password"
99 + x-model="$store.browserConfig.config.proxy_password"
100 + autocomplete="off"
101 + />
102 + <span class="browser-config-field-help">Saving proxy changes restarts active internal Browser sessions.</span>
103 + </label>
104 +
105 <label
106 class="browser-config-field"
107 x-show="$store.browserConfig.config.runtime_backend === 'host_required'"
@@ -324,6 +377,7 @@
377 }
378
379 .browser-config-field input[type="text"],
380 + .browser-config-field input[type="password"],
381 .browser-config-field input[type="number"],
382 .browser-config-field select {
383 width: 100%;
tests/test_browser_agent_regressions.py
+33 -5
@@ -164,6 +164,10 @@ def test_browser_config_normalizes_extension_paths(tmp_path):
164 "host_browser_privacy_policy": "allow",
165 "host_browser_profile_mode": "existing",
166 "host_browser_selection": "",
167 + "proxy_server": "",
168 + "proxy_bypass": "",
169 + "proxy_username": "",
170 + "proxy_password": "",
171 "model_preset": "",
172 }
173
@@ -187,6 +191,24 @@ def test_browser_config_normalizes_host_backend_and_privacy_policy():
191 assert config["host_browser_profile_mode"] == "agent"
192
193
194 +def test_browser_config_builds_authenticated_internal_proxy():
195 + config = normalize_browser_config(
196 + {
197 + "proxy_server": " socks5://proxy.example:1080 ",
198 + "proxy_bypass": " localhost, .example.com ",
199 + "proxy_username": "researcher",
200 + "proxy_password": "secret",
201 + }
202 + )
203 +
204 + assert build_browser_launch_config(config)["proxy"] == {
205 + "server": "socks5://proxy.example:1080",
206 + "bypass": "localhost, .example.com",
207 + "username": "researcher",
208 + "password": "secret",
209 + }
210 +
211 +
212 def test_browser_config_normalizes_host_browser_selection():
213 assert normalize_browser_config({})["host_browser_selection"] == ""
214 assert (
@@ -307,6 +329,7 @@ def test_browser_launch_config_uses_full_chromium_for_all_sessions(tmp_path):
329 assert default_launch["browser_mode"] == "chromium"
330 assert default_launch["channel"] is None
331 assert default_launch["requires_full_browser"] is True
332 + assert default_launch["proxy"] is None
333 assert not any(arg.startswith("--load-extension=") for arg in default_launch["args"])
334 assert "--headless=new" not in default_launch["args"]
335
@@ -1232,6 +1255,11 @@ def test_browser_extension_settings_stay_user_facing():
1255 assert "Maximum tabs per chat" in config_html
1256 assert 'x-model="$store.browserConfig.config.browser_tab_scope"' in config_html
1257 assert 'x-model.number="$store.browserConfig.config.max_open_tabs"' in config_html
1258 + assert 'x-model="$store.browserConfig.config.proxy_server"' in config_html
1259 + assert 'x-model="$store.browserConfig.config.proxy_bypass"' in config_html
1260 + assert 'x-model="$store.browserConfig.config.proxy_username"' in config_html
1261 + assert 'x-model="$store.browserConfig.config.proxy_password"' in config_html
1262 + assert 'type="password"' in config_html
1263 assert "normalizeMaxOpenTabs()" in config_html
1264 assert "BROWSER_TAB_SCOPES" in config_store
1265 assert "HARD_MAX_OPEN_TABS = 50" in config_store
@@ -2156,9 +2184,7 @@ def test_browser_runtime_context_close_event_clears_cached_state():
2184 assert core.last_interacted_browser_id is None
2185
2186
2159 -def test_browser_save_plugin_config_restarts_runtimes_on_change(monkeypatch, tmp_path):
2160 - extension_dir = tmp_path / "extension"
2161 - extension_dir.mkdir()
2187 +def test_browser_save_plugin_config_restarts_runtimes_on_change(monkeypatch):
2188 restarted = []
2189
2190 monkeypatch.setattr(
@@ -2166,6 +2192,7 @@ def test_browser_save_plugin_config_restarts_runtimes_on_change(monkeypatch, tmp
2192 "_load_saved_browser_config",
2193 lambda project_name="", agent_profile="": {
2194 "extension_paths": [],
2195 + "proxy_server": "",
2196 },
2197 )
2198 monkeypatch.setattr(
@@ -2176,13 +2203,14 @@ def test_browser_save_plugin_config_restarts_runtimes_on_change(monkeypatch, tmp
2203
2204 result = browser_hooks_module.save_plugin_config(
2205 {
2179 - "extension_paths": [str(extension_dir)],
2206 + "extension_paths": [],
2207 + "proxy_server": "http://proxy.example:3128",
2208 },
2209 project_name="",
2210 agent_profile="",
2211 )
2212
2185 - assert result["extension_paths"] == [str(extension_dir)]
2213 + assert result["proxy_server"] == "http://proxy.example:3128"
2214 assert result["model_preset"] == ""
2215 assert restarted == [True]
2216