Skip to main content

agentzero_tools/
proxy_config.rs

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/// Runtime proxy configuration tool for HTTP/SOCKS proxy settings.
62///
63/// Operations:
64/// - `get`: Get current proxy settings
65/// - `set`: Set a proxy URL for a protocol (http, https, socks)
66/// - `clear`: Clear a proxy setting for a protocol
67/// - `add_bypass`: Add a host to the no_proxy bypass list
68/// - `remove_bypass`: Remove a host from the no_proxy bypass list
69#[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}