Skip to main content

zeroclaw_tools/
proxy_config.rs

1use crate::util_helpers::MaybeSet;
2use async_trait::async_trait;
3use serde_json::{Value, json};
4use std::fs;
5use std::sync::Arc;
6use zeroclaw_api::tool::{Tool, ToolResult};
7use zeroclaw_config::policy::SecurityPolicy;
8use zeroclaw_config::schema::{
9    Config, ProxyConfig, ProxyScope, runtime_proxy_config, set_runtime_proxy_config,
10};
11
12pub struct ProxyConfigTool {
13    config: Arc<Config>,
14    security: Arc<SecurityPolicy>,
15}
16
17impl ProxyConfigTool {
18    pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
19        Self { config, security }
20    }
21
22    fn load_config_without_env(&self) -> anyhow::Result<Config> {
23        let contents = fs::read_to_string(&self.config.config_path).map_err(|error| {
24            ::zeroclaw_log::record!(
25                ERROR,
26                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
27                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
28                    .with_attrs(::serde_json::json!({
29                        "path": self.config.config_path.display().to_string(),
30                        "error": format!("{}", error),
31                    })),
32                "proxy_config: failed to read config file"
33            );
34            anyhow::Error::msg(format!(
35                "Failed to read config file {}: {error}",
36                self.config.config_path.display()
37            ))
38        })?;
39
40        let mut parsed: Config = toml::from_str(&contents).map_err(|error| {
41            ::zeroclaw_log::record!(
42                ERROR,
43                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Fail)
44                    .with_outcome(::zeroclaw_log::EventOutcome::Failure)
45                    .with_attrs(::serde_json::json!({
46                        "path": self.config.config_path.display().to_string(),
47                        "error": format!("{}", error),
48                    })),
49                "proxy_config: failed to parse config file"
50            );
51            anyhow::Error::msg(format!(
52                "Failed to parse config file {}: {error}",
53                self.config.config_path.display()
54            ))
55        })?;
56        parsed.config_path = self.config.config_path.clone();
57        parsed.data_dir = self.config.data_dir.clone();
58        Ok(parsed)
59    }
60
61    fn require_write_access(&self) -> Option<ToolResult> {
62        if !self.security.can_act() {
63            return Some(ToolResult {
64                success: false,
65                output: String::new(),
66                error: Some("Action blocked: autonomy is read-only".into()),
67            });
68        }
69
70        if !self.security.record_action() {
71            return Some(ToolResult {
72                success: false,
73                output: String::new(),
74                error: Some("Action blocked: rate limit exceeded".into()),
75            });
76        }
77
78        None
79    }
80
81    fn parse_scope(raw: &str) -> Option<ProxyScope> {
82        match raw.trim().to_ascii_lowercase().as_str() {
83            "environment" | "env" => Some(ProxyScope::Environment),
84            "zeroclaw" | "internal" | "core" => Some(ProxyScope::Zeroclaw),
85            "services" | "service" => Some(ProxyScope::Services),
86            _ => None,
87        }
88    }
89
90    fn parse_string_list(raw: &Value, field: &str) -> anyhow::Result<Vec<String>> {
91        if let Some(raw_string) = raw.as_str() {
92            return Ok(raw_string
93                .split(',')
94                .map(str::trim)
95                .filter(|entry| !entry.is_empty())
96                .map(ToOwned::to_owned)
97                .collect());
98        }
99
100        if let Some(array) = raw.as_array() {
101            let mut out = Vec::new();
102            for item in array {
103                let value = item.as_str().ok_or_else(|| {
104                    ::zeroclaw_log::record!(
105                        WARN,
106                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
107                            .with_outcome(::zeroclaw_log::EventOutcome::Failure)
108                            .with_attrs(::serde_json::json!({"field": field})),
109                        "proxy_config: array element must be a string"
110                    );
111                    anyhow::Error::msg(format!("'{field}' array must only contain strings"))
112                })?;
113                let trimmed = value.trim();
114                if !trimmed.is_empty() {
115                    out.push(trimmed.to_string());
116                }
117            }
118            return Ok(out);
119        }
120
121        anyhow::bail!("'{field}' must be a string or string[]")
122    }
123
124    fn parse_optional_string_update(args: &Value, field: &str) -> anyhow::Result<MaybeSet<String>> {
125        let Some(raw) = args.get(field) else {
126            return Ok(MaybeSet::Unset);
127        };
128
129        if raw.is_null() {
130            return Ok(MaybeSet::Null);
131        }
132
133        let value = raw
134            .as_str()
135            .ok_or_else(|| {
136                ::zeroclaw_log::record!(
137                    WARN,
138                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
139                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
140                        .with_attrs(::serde_json::json!({"field": field})),
141                    "proxy_config: field must be a string or null"
142                );
143                anyhow::Error::msg(format!("'{field}' must be a string or null"))
144            })?
145            .trim()
146            .to_string();
147
148        let output = if value.is_empty() {
149            MaybeSet::Null
150        } else {
151            MaybeSet::Set(value)
152        };
153        Ok(output)
154    }
155
156    fn env_snapshot() -> Value {
157        json!({
158            "HTTP_PROXY": std::env::var("HTTP_PROXY").ok(),
159            "HTTPS_PROXY": std::env::var("HTTPS_PROXY").ok(),
160            "ALL_PROXY": std::env::var("ALL_PROXY").ok(),
161            "NO_PROXY": std::env::var("NO_PROXY").ok(),
162        })
163    }
164
165    fn proxy_json(proxy: &ProxyConfig) -> Value {
166        json!({
167            "enabled": proxy.enabled,
168            "scope": proxy.scope,
169            "http_proxy": proxy.http_proxy,
170            "https_proxy": proxy.https_proxy,
171            "all_proxy": proxy.all_proxy,
172            "no_proxy": proxy.normalized_no_proxy(),
173            "services": proxy.normalized_services(),
174        })
175    }
176
177    fn handle_get(&self) -> anyhow::Result<ToolResult> {
178        let file_proxy = self.load_config_without_env()?.proxy;
179        let runtime_proxy = runtime_proxy_config();
180        Ok(ToolResult {
181            success: true,
182            output: serde_json::to_string_pretty(&json!({
183                "proxy": Self::proxy_json(&file_proxy),
184                "runtime_proxy": Self::proxy_json(&runtime_proxy),
185                "environment": Self::env_snapshot(),
186            }))?,
187            error: None,
188        })
189    }
190
191    fn handle_list_services(&self) -> anyhow::Result<ToolResult> {
192        Ok(ToolResult {
193            success: true,
194            output: serde_json::to_string_pretty(&json!({
195                "supported_service_keys": ProxyConfig::supported_service_keys(),
196                "supported_selectors": ProxyConfig::supported_service_selectors(),
197                "usage_example": {
198                    "action": "set",
199                    "scope": "services",
200                    "services": ["model_provider.openai", "tool.http_request", "channel.telegram"]
201                }
202            }))?,
203            error: None,
204        })
205    }
206
207    async fn handle_set(&self, args: &Value) -> anyhow::Result<ToolResult> {
208        let mut cfg = self.load_config_without_env()?;
209        let previous_scope = cfg.proxy.scope;
210        let mut proxy = cfg.proxy.clone();
211        let mut touched_proxy_url = false;
212
213        if let Some(enabled) = args.get("enabled") {
214            proxy.enabled = enabled.as_bool().ok_or_else(|| {
215                ::zeroclaw_log::record!(
216                    WARN,
217                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
218                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
219                        .with_attrs(::serde_json::json!({"param": "enabled"})),
220                    "proxy_config: enabled must be boolean"
221                );
222                anyhow::Error::msg("'enabled' must be a boolean")
223            })?;
224        }
225
226        if let Some(scope_raw) = args.get("scope") {
227            let scope = scope_raw.as_str().ok_or_else(|| {
228                ::zeroclaw_log::record!(
229                    WARN,
230                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
231                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
232                        .with_attrs(::serde_json::json!({"param": "scope"})),
233                    "proxy_config: scope must be string"
234                );
235                anyhow::Error::msg("'scope' must be a string")
236            })?;
237            proxy.scope = Self::parse_scope(scope).ok_or_else(|| {
238                ::zeroclaw_log::record!(
239                    WARN,
240                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
241                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
242                        .with_attrs(::serde_json::json!({"scope": scope})),
243                    "proxy_config: invalid scope"
244                );
245                anyhow::Error::msg(format!(
246                    "Invalid scope '{scope}'. Use environment|zeroclaw|services"
247                ))
248            })?;
249        }
250
251        match Self::parse_optional_string_update(args, "http_proxy")? {
252            MaybeSet::Set(update) => {
253                proxy.http_proxy = Some(update);
254                touched_proxy_url = true;
255            }
256            MaybeSet::Null => {
257                proxy.http_proxy = None;
258                touched_proxy_url = true;
259            }
260            MaybeSet::Unset => {}
261        }
262
263        match Self::parse_optional_string_update(args, "https_proxy")? {
264            MaybeSet::Set(update) => {
265                proxy.https_proxy = Some(update);
266                touched_proxy_url = true;
267            }
268            MaybeSet::Null => {
269                proxy.https_proxy = None;
270                touched_proxy_url = true;
271            }
272            MaybeSet::Unset => {}
273        }
274
275        match Self::parse_optional_string_update(args, "all_proxy")? {
276            MaybeSet::Set(update) => {
277                proxy.all_proxy = Some(update);
278                touched_proxy_url = true;
279            }
280            MaybeSet::Null => {
281                proxy.all_proxy = None;
282                touched_proxy_url = true;
283            }
284            MaybeSet::Unset => {}
285        }
286
287        if let Some(no_proxy_raw) = args.get("no_proxy") {
288            proxy.no_proxy = Self::parse_string_list(no_proxy_raw, "no_proxy")?;
289            touched_proxy_url = true;
290        }
291
292        if let Some(services_raw) = args.get("services") {
293            proxy.services = Self::parse_string_list(services_raw, "services")?;
294        }
295
296        if args.get("enabled").is_none() && touched_proxy_url {
297            // Keep auto-enable behavior when users provide a proxy URL, but
298            // auto-disable when all proxy URLs are cleared in the same update.
299            proxy.enabled = proxy.has_any_proxy_url();
300        }
301
302        proxy.no_proxy = proxy.normalized_no_proxy();
303        proxy.services = proxy.normalized_services();
304        proxy.validate()?;
305
306        cfg.proxy = proxy.clone();
307        cfg.save().await?;
308        set_runtime_proxy_config(proxy.clone());
309
310        if proxy.enabled && proxy.scope == ProxyScope::Environment {
311            proxy.apply_to_process_env();
312        } else if previous_scope == ProxyScope::Environment {
313            ProxyConfig::clear_process_env();
314        }
315
316        Ok(ToolResult {
317            success: true,
318            output: serde_json::to_string_pretty(&json!({
319                "message": "Proxy configuration updated",
320                "proxy": Self::proxy_json(&proxy),
321                "environment": Self::env_snapshot(),
322            }))?,
323            error: None,
324        })
325    }
326
327    async fn handle_disable(&self, args: &Value) -> anyhow::Result<ToolResult> {
328        let mut cfg = self.load_config_without_env()?;
329        let clear_env_default = cfg.proxy.scope == ProxyScope::Environment;
330        cfg.proxy.enabled = false;
331        cfg.save().await?;
332
333        set_runtime_proxy_config(cfg.proxy.clone());
334
335        let clear_env = args
336            .get("clear_env")
337            .and_then(Value::as_bool)
338            .unwrap_or(clear_env_default);
339        if clear_env {
340            ProxyConfig::clear_process_env();
341        }
342
343        Ok(ToolResult {
344            success: true,
345            output: serde_json::to_string_pretty(&json!({
346                "message": "Proxy disabled",
347                "proxy": Self::proxy_json(&cfg.proxy),
348                "environment": Self::env_snapshot(),
349            }))?,
350            error: None,
351        })
352    }
353
354    fn handle_apply_env(&self) -> anyhow::Result<ToolResult> {
355        let cfg = self.load_config_without_env()?;
356        let proxy = cfg.proxy;
357        proxy.validate()?;
358
359        if !proxy.enabled {
360            anyhow::bail!("Proxy is disabled. Use action 'set' with enabled=true first");
361        }
362
363        if proxy.scope != ProxyScope::Environment {
364            anyhow::bail!(
365                "apply_env only works when proxy.scope is 'environment' (current: {:?})",
366                proxy.scope
367            );
368        }
369
370        proxy.apply_to_process_env();
371        set_runtime_proxy_config(proxy.clone());
372
373        Ok(ToolResult {
374            success: true,
375            output: serde_json::to_string_pretty(&json!({
376                "message": "Proxy environment variables applied",
377                "proxy": Self::proxy_json(&proxy),
378                "environment": Self::env_snapshot(),
379            }))?,
380            error: None,
381        })
382    }
383
384    fn handle_clear_env(&self) -> anyhow::Result<ToolResult> {
385        ProxyConfig::clear_process_env();
386        Ok(ToolResult {
387            success: true,
388            output: serde_json::to_string_pretty(&json!({
389                "message": "Proxy environment variables cleared",
390                "environment": Self::env_snapshot(),
391            }))?,
392            error: None,
393        })
394    }
395}
396
397#[async_trait]
398impl Tool for ProxyConfigTool {
399    fn name(&self) -> &str {
400        "proxy_config"
401    }
402
403    fn description(&self) -> &str {
404        "Manage ZeroClaw proxy settings (scope: environment | zeroclaw | services), including runtime and process env application"
405    }
406
407    fn parameters_schema(&self) -> Value {
408        json!({
409            "type": "object",
410            "properties": {
411                "action": {
412                    "type": "string",
413                    "enum": ["get", "set", "disable", "list_services", "apply_env", "clear_env"],
414                    "default": "get"
415                },
416                "enabled": {
417                    "type": "boolean",
418                    "description": "Enable or disable proxy"
419                },
420                "scope": {
421                    "type": "string",
422                    "description": "Proxy scope: environment | zeroclaw | services"
423                },
424                "http_proxy": {
425                    "type": ["string", "null"],
426                    "description": "HTTP proxy URL"
427                },
428                "https_proxy": {
429                    "type": ["string", "null"],
430                    "description": "HTTPS proxy URL"
431                },
432                "all_proxy": {
433                    "type": ["string", "null"],
434                    "description": "Fallback proxy URL for all protocols"
435                },
436                "no_proxy": {
437                    "description": "Comma-separated string or array of NO_PROXY entries",
438                    "oneOf": [
439                        {"type": "string"},
440                        {"type": "array", "items": {"type": "string"}}
441                    ]
442                },
443                "services": {
444                    "description": "Comma-separated string or array of service selectors used when scope=services",
445                    "oneOf": [
446                        {"type": "string"},
447                        {"type": "array", "items": {"type": "string"}}
448                    ]
449                },
450                "clear_env": {
451                    "type": "boolean",
452                    "description": "When action=disable, clear process proxy environment variables"
453                }
454            }
455        })
456    }
457
458    async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
459        let action = args
460            .get("action")
461            .and_then(Value::as_str)
462            .unwrap_or("get")
463            .to_ascii_lowercase();
464
465        let result = match action.as_str() {
466            "get" => self.handle_get(),
467            "list_services" => self.handle_list_services(),
468            "set" | "disable" | "apply_env" | "clear_env" => {
469                if let Some(blocked) = self.require_write_access() {
470                    return Ok(blocked);
471                }
472
473                match action.as_str() {
474                    "set" => Box::pin(self.handle_set(&args)).await,
475                    "disable" => Box::pin(self.handle_disable(&args)).await,
476                    "apply_env" => self.handle_apply_env(),
477                    "clear_env" => self.handle_clear_env(),
478                    _ => unreachable!("handled above"),
479                }
480            }
481            _ => anyhow::bail!(
482                "Unknown action '{action}'. Valid: get, set, disable, list_services, apply_env, clear_env"
483            ),
484        };
485
486        match result {
487            Ok(outcome) => Ok(outcome),
488            Err(error) => Ok(ToolResult {
489                success: false,
490                output: String::new(),
491                error: Some(error.to_string()),
492            }),
493        }
494    }
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use tempfile::TempDir;
501    use zeroclaw_config::autonomy::AutonomyLevel;
502    use zeroclaw_config::policy::SecurityPolicy;
503
504    fn test_security() -> Arc<SecurityPolicy> {
505        Arc::new(SecurityPolicy {
506            autonomy: AutonomyLevel::Supervised,
507            workspace_dir: std::env::temp_dir(),
508            ..SecurityPolicy::default()
509        })
510    }
511
512    async fn test_config(tmp: &TempDir) -> Arc<Config> {
513        let config = Config {
514            data_dir: tmp.path().join("data"),
515            config_path: tmp.path().join("config.toml"),
516            ..Config::default()
517        };
518        config.save().await.unwrap();
519        Arc::new(config)
520    }
521
522    #[tokio::test]
523    async fn list_services_action_returns_known_keys() {
524        let tmp = TempDir::new().unwrap();
525        let tool = ProxyConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
526
527        let result = tool
528            .execute(json!({"action": "list_services"}))
529            .await
530            .unwrap();
531        assert!(result.success);
532        assert!(result.output.contains("model_provider.openai"));
533        assert!(result.output.contains("tool.http_request"));
534    }
535
536    #[tokio::test]
537    async fn set_scope_services_requires_services_entries() {
538        let tmp = TempDir::new().unwrap();
539        let tool = ProxyConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
540
541        let result = tool
542            .execute(json!({
543                "action": "set",
544                "enabled": true,
545                "scope": "services",
546                "http_proxy": "http://127.0.0.1:7890",
547                "services": []
548            }))
549            .await
550            .unwrap();
551
552        assert!(!result.success);
553        assert!(
554            result
555                .error
556                .unwrap_or_default()
557                .contains("proxy.scope='services'")
558        );
559    }
560
561    #[tokio::test]
562    async fn set_and_get_round_trip_proxy_scope() {
563        let tmp = TempDir::new().unwrap();
564        let tool = ProxyConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
565
566        let set_result = tool
567            .execute(json!({
568                "action": "set",
569                "scope": "services",
570                "http_proxy": "http://127.0.0.1:7890",
571                "services": ["model_provider.openai", "tool.http_request"]
572            }))
573            .await
574            .unwrap();
575        assert!(set_result.success, "{:?}", set_result.error);
576
577        let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
578        assert!(get_result.success);
579        assert!(get_result.output.contains("model_provider.openai"));
580        assert!(get_result.output.contains("services"));
581    }
582
583    #[tokio::test]
584    async fn set_null_proxy_url_clears_existing_value() {
585        let tmp = TempDir::new().unwrap();
586        let tool = ProxyConfigTool::new(Box::pin(test_config(&tmp)).await, test_security());
587
588        let set_result = tool
589            .execute(json!({
590                "action": "set",
591                "http_proxy": "http://127.0.0.1:7890"
592            }))
593            .await
594            .unwrap();
595        assert!(set_result.success, "{:?}", set_result.error);
596
597        let clear_result = tool
598            .execute(json!({
599                "action": "set",
600                "http_proxy": null
601            }))
602            .await
603            .unwrap();
604        assert!(clear_result.success, "{:?}", clear_result.error);
605
606        let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
607        assert!(get_result.success);
608        let parsed: Value = serde_json::from_str(&get_result.output).unwrap();
609        assert!(parsed["proxy"]["http_proxy"].is_null());
610        assert!(parsed["runtime_proxy"]["http_proxy"].is_null());
611    }
612}