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