1use agentzero_core::{Tool, ToolContext, ToolResult};
2use anyhow::{anyhow, Context};
3use async_trait::async_trait;
4use serde::{Deserialize, Serialize};
5use serde_json::json;
6use std::path::Path;
7use tokio::fs;
8
9const PROXY_FILE: &str = ".agentzero/proxy.json";
10
11#[derive(Debug, Clone, Default, Serialize, Deserialize)]
12struct ProxySettings {
13 #[serde(default)]
14 http_proxy: Option<String>,
15 #[serde(default)]
16 https_proxy: Option<String>,
17 #[serde(default)]
18 socks_proxy: Option<String>,
19 #[serde(default)]
20 no_proxy: Vec<String>,
21}
22
23impl ProxySettings {
24 async fn load(workspace_root: &str) -> anyhow::Result<Self> {
25 let path = Path::new(workspace_root).join(PROXY_FILE);
26 if !path.exists() {
27 return Ok(Self::default());
28 }
29 let data = fs::read_to_string(&path)
30 .await
31 .context("failed to read proxy config")?;
32 serde_json::from_str(&data).context("failed to parse proxy config")
33 }
34
35 async fn save(&self, workspace_root: &str) -> anyhow::Result<()> {
36 let path = Path::new(workspace_root).join(PROXY_FILE);
37 if let Some(parent) = path.parent() {
38 fs::create_dir_all(parent)
39 .await
40 .context("failed to create .agentzero directory")?;
41 }
42 let data =
43 serde_json::to_string_pretty(self).context("failed to serialize proxy config")?;
44 fs::write(&path, data)
45 .await
46 .context("failed to write proxy config")
47 }
48}
49
50#[derive(Debug, Deserialize)]
51struct Input {
52 op: String,
53 #[serde(default)]
54 protocol: Option<String>,
55 #[serde(default)]
56 url: Option<String>,
57 #[serde(default)]
58 host: Option<String>,
59}
60
61#[derive(Debug, Default, Clone, Copy)]
70pub struct ProxyConfigTool;
71
72#[async_trait]
73impl Tool for ProxyConfigTool {
74 fn name(&self) -> &'static str {
75 "proxy_config"
76 }
77
78 fn description(&self) -> &'static str {
79 "Manage HTTP/HTTPS proxy settings: get, set, clear, add/remove bypass hosts."
80 }
81
82 fn input_schema(&self) -> Option<serde_json::Value> {
83 Some(json!({
84 "type": "object",
85 "required": ["op"],
86 "properties": {
87 "op": {"type": "string", "description": "Operation: get, set, clear, add_bypass, remove_bypass"},
88 "protocol": {"type": "string", "description": "Protocol: http, https, or socks"},
89 "url": {"type": "string", "description": "Proxy URL (for set op)"},
90 "host": {"type": "string", "description": "Bypass host (for add_bypass/remove_bypass ops)"}
91 }
92 }))
93 }
94
95 async fn execute(&self, input: &str, ctx: &ToolContext) -> anyhow::Result<ToolResult> {
96 let parsed: Input =
97 serde_json::from_str(input).context("proxy_config expects JSON: {\"op\", ...}")?;
98
99 match parsed.op.as_str() {
100 "get" => {
101 let settings = ProxySettings::load(&ctx.workspace_root).await?;
102 let output = json!({
103 "http_proxy": settings.http_proxy,
104 "https_proxy": settings.https_proxy,
105 "socks_proxy": settings.socks_proxy,
106 "no_proxy": settings.no_proxy,
107 })
108 .to_string();
109 Ok(ToolResult { output })
110 }
111 "set" => {
112 let protocol = parsed
113 .protocol
114 .as_deref()
115 .ok_or_else(|| anyhow!("set requires a `protocol` field"))?;
116 let url = parsed
117 .url
118 .as_deref()
119 .ok_or_else(|| anyhow!("set requires a `url` field"))?;
120
121 if url.trim().is_empty() {
122 return Err(anyhow!("url must not be empty"));
123 }
124
125 let mut settings = ProxySettings::load(&ctx.workspace_root).await?;
126 match protocol {
127 "http" => settings.http_proxy = Some(url.to_string()),
128 "https" => settings.https_proxy = Some(url.to_string()),
129 "socks" => settings.socks_proxy = Some(url.to_string()),
130 other => {
131 return Err(anyhow!(
132 "unknown protocol: {other} (use http, https, or socks)"
133 ))
134 }
135 }
136 settings.save(&ctx.workspace_root).await?;
137
138 Ok(ToolResult {
139 output: format!("set {protocol}_proxy={url}"),
140 })
141 }
142 "clear" => {
143 let protocol = parsed
144 .protocol
145 .as_deref()
146 .ok_or_else(|| anyhow!("clear requires a `protocol` field"))?;
147
148 let mut settings = ProxySettings::load(&ctx.workspace_root).await?;
149 match protocol {
150 "http" => settings.http_proxy = None,
151 "https" => settings.https_proxy = None,
152 "socks" => settings.socks_proxy = None,
153 other => {
154 return Err(anyhow!(
155 "unknown protocol: {other} (use http, https, or socks)"
156 ))
157 }
158 }
159 settings.save(&ctx.workspace_root).await?;
160
161 Ok(ToolResult {
162 output: format!("cleared {protocol}_proxy"),
163 })
164 }
165 "add_bypass" => {
166 let host = parsed
167 .host
168 .as_deref()
169 .ok_or_else(|| anyhow!("add_bypass requires a `host` field"))?;
170
171 if host.trim().is_empty() {
172 return Err(anyhow!("host must not be empty"));
173 }
174
175 let mut settings = ProxySettings::load(&ctx.workspace_root).await?;
176 if !settings.no_proxy.contains(&host.to_string()) {
177 settings.no_proxy.push(host.to_string());
178 settings.save(&ctx.workspace_root).await?;
179 }
180
181 Ok(ToolResult {
182 output: format!("added bypass for {host}"),
183 })
184 }
185 "remove_bypass" => {
186 let host = parsed
187 .host
188 .as_deref()
189 .ok_or_else(|| anyhow!("remove_bypass requires a `host` field"))?;
190
191 let mut settings = ProxySettings::load(&ctx.workspace_root).await?;
192 let before = settings.no_proxy.len();
193 settings.no_proxy.retain(|h| h != host);
194 let removed = before != settings.no_proxy.len();
195 if removed {
196 settings.save(&ctx.workspace_root).await?;
197 }
198
199 Ok(ToolResult {
200 output: if removed {
201 format!("removed bypass for {host}")
202 } else {
203 format!("host not in bypass list: {host}")
204 },
205 })
206 }
207 other => Ok(ToolResult {
208 output: json!({ "error": format!("unknown op: {other}") }).to_string(),
209 }),
210 }
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use agentzero_core::ToolContext;
218 use std::fs;
219 use std::path::PathBuf;
220 use std::sync::atomic::{AtomicU64, Ordering};
221 use std::time::{SystemTime, UNIX_EPOCH};
222
223 static TEMP_COUNTER: AtomicU64 = AtomicU64::new(0);
224
225 fn temp_dir() -> PathBuf {
226 let nanos = SystemTime::now()
227 .duration_since(UNIX_EPOCH)
228 .expect("clock")
229 .as_nanos();
230 let seq = TEMP_COUNTER.fetch_add(1, Ordering::Relaxed);
231 let dir = std::env::temp_dir().join(format!(
232 "agentzero-proxy-tools-{}-{nanos}-{seq}",
233 std::process::id()
234 ));
235 fs::create_dir_all(&dir).expect("temp dir should be created");
236 dir
237 }
238
239 #[tokio::test]
240 async fn proxy_get_empty_defaults() {
241 let dir = temp_dir();
242 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
243
244 let result = ProxyConfigTool
245 .execute(r#"{"op": "get"}"#, &ctx)
246 .await
247 .expect("get should succeed");
248 let v: serde_json::Value = serde_json::from_str(&result.output).unwrap();
249 assert!(v["http_proxy"].is_null());
250 assert!(v["https_proxy"].is_null());
251 assert!(v["no_proxy"].as_array().unwrap().is_empty());
252
253 fs::remove_dir_all(dir).ok();
254 }
255
256 #[tokio::test]
257 async fn proxy_set_and_get_roundtrip() {
258 let dir = temp_dir();
259 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
260
261 ProxyConfigTool
262 .execute(
263 r#"{"op": "set", "protocol": "http", "url": "http://proxy:8080"}"#,
264 &ctx,
265 )
266 .await
267 .expect("set should succeed");
268
269 let result = ProxyConfigTool
270 .execute(r#"{"op": "get"}"#, &ctx)
271 .await
272 .expect("get should succeed");
273 let v: serde_json::Value = serde_json::from_str(&result.output).unwrap();
274 assert_eq!(v["http_proxy"], "http://proxy:8080");
275
276 fs::remove_dir_all(dir).ok();
277 }
278
279 #[tokio::test]
280 async fn proxy_clear_removes_setting() {
281 let dir = temp_dir();
282 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
283
284 ProxyConfigTool
285 .execute(
286 r#"{"op": "set", "protocol": "socks", "url": "socks5://127.0.0.1:1080"}"#,
287 &ctx,
288 )
289 .await
290 .unwrap();
291
292 ProxyConfigTool
293 .execute(r#"{"op": "clear", "protocol": "socks"}"#, &ctx)
294 .await
295 .expect("clear should succeed");
296
297 let result = ProxyConfigTool
298 .execute(r#"{"op": "get"}"#, &ctx)
299 .await
300 .unwrap();
301 let v: serde_json::Value = serde_json::from_str(&result.output).unwrap();
302 assert!(v["socks_proxy"].is_null());
303
304 fs::remove_dir_all(dir).ok();
305 }
306
307 #[tokio::test]
308 async fn proxy_bypass_add_and_remove() {
309 let dir = temp_dir();
310 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
311
312 ProxyConfigTool
313 .execute(r#"{"op": "add_bypass", "host": "localhost"}"#, &ctx)
314 .await
315 .expect("add_bypass should succeed");
316
317 ProxyConfigTool
318 .execute(r#"{"op": "add_bypass", "host": "127.0.0.1"}"#, &ctx)
319 .await
320 .unwrap();
321
322 let result = ProxyConfigTool
323 .execute(r#"{"op": "get"}"#, &ctx)
324 .await
325 .unwrap();
326 let v: serde_json::Value = serde_json::from_str(&result.output).unwrap();
327 let no_proxy = v["no_proxy"].as_array().unwrap();
328 assert_eq!(no_proxy.len(), 2);
329
330 let result = ProxyConfigTool
331 .execute(r#"{"op": "remove_bypass", "host": "localhost"}"#, &ctx)
332 .await
333 .expect("remove_bypass should succeed");
334 assert!(result.output.contains("removed bypass"));
335
336 fs::remove_dir_all(dir).ok();
337 }
338
339 #[tokio::test]
340 async fn proxy_set_unknown_protocol_fails() {
341 let dir = temp_dir();
342 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
343
344 let err = ProxyConfigTool
345 .execute(
346 r#"{"op": "set", "protocol": "ftp", "url": "ftp://proxy:21"}"#,
347 &ctx,
348 )
349 .await
350 .expect_err("unknown protocol should fail");
351 assert!(err.to_string().contains("unknown protocol"));
352
353 fs::remove_dir_all(dir).ok();
354 }
355
356 #[tokio::test]
357 async fn proxy_set_empty_url_fails() {
358 let dir = temp_dir();
359 let ctx = ToolContext::new(dir.to_string_lossy().to_string());
360
361 let err = ProxyConfigTool
362 .execute(r#"{"op": "set", "protocol": "http", "url": ""}"#, &ctx)
363 .await
364 .expect_err("empty url should fail");
365 assert!(err.to_string().contains("url must not be empty"));
366
367 fs::remove_dir_all(dir).ok();
368 }
369}