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 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}