feat(proxy): add scoped proxy configuration and docs runbooks

- add scope-aware proxy schema and runtime wiring for providers/channels/tools

- add agent callable proxy_config tool for fast proxy setup

- standardize docs system with index, template, and playbooks
This commit is contained in:
Chummy 2026-02-18 21:09:01 +08:00
parent 13ee9e6398
commit ce104bed45
36 changed files with 2025 additions and 323 deletions

View file

@ -15,7 +15,6 @@ pub struct DingTalkChannel {
client_id: String,
client_secret: String,
allowed_users: Vec<String>,
client: reqwest::Client,
/// Per-chat session webhooks for sending replies (chatID -> webhook URL).
/// DingTalk provides a unique webhook URL with each incoming message.
session_webhooks: Arc<RwLock<HashMap<String, String>>>,
@ -34,11 +33,14 @@ impl DingTalkChannel {
client_id,
client_secret,
allowed_users,
client: reqwest::Client::new(),
session_webhooks: Arc::new(RwLock::new(HashMap::new())),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.dingtalk")
}
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
@ -86,7 +88,7 @@ impl DingTalkChannel {
});
let resp = self
.client
.http_client()
.post("https://api.dingtalk.com/v1.0/gateway/connections/open")
.json(&body)
.send()
@ -128,7 +130,12 @@ impl Channel for DingTalkChannel {
}
});
let resp = self.client.post(webhook_url).json(&body).send().await?;
let resp = self
.http_client()
.post(webhook_url)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();

View file

@ -13,7 +13,6 @@ pub struct DiscordChannel {
allowed_users: Vec<String>,
listen_to_bots: bool,
mention_only: bool,
client: reqwest::Client,
typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
@ -31,11 +30,14 @@ impl DiscordChannel {
allowed_users,
listen_to_bots,
mention_only,
client: reqwest::Client::new(),
typing_handle: Mutex::new(None),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.discord")
}
/// Check if a Discord user ID is in the allowlist.
/// Empty list means deny everyone until explicitly configured.
/// `"*"` means allow everyone.
@ -198,7 +200,7 @@ impl Channel for DiscordChannel {
let body = json!({ "content": chunk });
let resp = self
.client
.http_client()
.post(&url)
.header("Authorization", format!("Bot {}", self.bot_token))
.json(&body)
@ -229,7 +231,7 @@ impl Channel for DiscordChannel {
// Get Gateway URL
let gw_resp: serde_json::Value = self
.client
.http_client()
.get("https://discord.com/api/v10/gateway/bot")
.header("Authorization", format!("Bot {}", self.bot_token))
.send()
@ -424,7 +426,7 @@ impl Channel for DiscordChannel {
}
async fn health_check(&self) -> bool {
self.client
self.http_client()
.get("https://discord.com/api/v10/users/@me")
.header("Authorization", format!("Bot {}", self.bot_token))
.send()
@ -436,7 +438,7 @@ impl Channel for DiscordChannel {
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
self.stop_typing(recipient).await?;
let client = self.client.clone();
let client = self.http_client();
let token = self.bot_token.clone();
let channel_id = recipient.to_string();

View file

@ -142,7 +142,6 @@ pub struct LarkChannel {
use_feishu: bool,
/// How to receive events: WebSocket long-connection or HTTP webhook.
receive_mode: crate::config::schema::LarkReceiveMode,
client: reqwest::Client,
/// Cached tenant access token
tenant_token: Arc<RwLock<Option<String>>>,
/// Dedup set: WS message_ids seen in last ~30 min to prevent double-dispatch
@ -165,7 +164,6 @@ impl LarkChannel {
allowed_users,
use_feishu: true,
receive_mode: crate::config::schema::LarkReceiveMode::default(),
client: reqwest::Client::new(),
tenant_token: Arc::new(RwLock::new(None)),
ws_seen_ids: Arc::new(RwLock::new(HashMap::new())),
}
@ -185,6 +183,10 @@ impl LarkChannel {
ch
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.lark")
}
fn api_base(&self) -> &'static str {
if self.use_feishu {
FEISHU_BASE_URL
@ -212,7 +214,7 @@ impl LarkChannel {
/// POST /callback/ws/endpoint → (wss_url, client_config)
async fn get_ws_endpoint(&self) -> anyhow::Result<(String, WsClientConfig)> {
let resp = self
.client
.http_client()
.post(format!("{}/callback/ws/endpoint", self.ws_base()))
.header("locale", if self.use_feishu { "zh" } else { "en" })
.json(&serde_json::json!({
@ -488,7 +490,7 @@ impl LarkChannel {
"app_secret": self.app_secret,
});
let resp = self.client.post(&url).json(&body).send().await?;
let resp = self.http_client().post(&url).json(&body).send().await?;
let data: serde_json::Value = resp.json().await?;
let code = data.get("code").and_then(|c| c.as_i64()).unwrap_or(-1);
@ -642,7 +644,7 @@ impl Channel for LarkChannel {
});
let resp = self
.client
.http_client()
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.header("Content-Type", "application/json; charset=utf-8")
@ -655,7 +657,7 @@ impl Channel for LarkChannel {
self.invalidate_token().await;
let new_token = self.get_tenant_access_token().await?;
let retry_resp = self
.client
.http_client()
.post(&url)
.header("Authorization", format!("Bearer {new_token}"))
.header("Content-Type", "application/json; charset=utf-8")

View file

@ -12,7 +12,6 @@ pub struct MatrixChannel {
access_token: String,
room_id: String,
allowed_users: Vec<String>,
client: Client,
}
#[derive(Debug, Deserialize)]
@ -79,10 +78,13 @@ impl MatrixChannel {
access_token,
room_id,
allowed_users,
client: Client::new(),
}
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client("channel.matrix")
}
fn is_user_allowed(&self, sender: &str) -> bool {
if self.allowed_users.iter().any(|u| u == "*") {
return true;
@ -95,7 +97,7 @@ impl MatrixChannel {
async fn get_my_user_id(&self) -> anyhow::Result<String> {
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
let resp = self
.client
.http_client()
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.send()
@ -130,7 +132,7 @@ impl Channel for MatrixChannel {
});
let resp = self
.client
.http_client()
.put(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.json(&body)
@ -157,7 +159,7 @@ impl Channel for MatrixChannel {
);
let resp = self
.client
.http_client()
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.send()
@ -179,7 +181,7 @@ impl Channel for MatrixChannel {
);
let resp = self
.client
.http_client()
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.send()
@ -250,7 +252,7 @@ impl Channel for MatrixChannel {
async fn health_check(&self) -> bool {
let url = format!("{}/_matrix/client/v3/account/whoami", self.homeserver);
let Ok(resp) = self
.client
.http_client()
.get(&url)
.header("Authorization", format!("Bearer {}", self.access_token))
.send()

View file

@ -15,7 +15,6 @@ pub struct MattermostChannel {
thread_replies: bool,
/// When true, only respond to messages that @-mention the bot.
mention_only: bool,
client: reqwest::Client,
/// Handle for the background typing-indicator loop (aborted on stop_typing).
typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
}
@ -38,11 +37,14 @@ impl MattermostChannel {
allowed_users,
thread_replies,
mention_only,
client: reqwest::Client::new(),
typing_handle: Mutex::new(None),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.mattermost")
}
/// Check if a user ID is in the allowlist.
/// Empty list means deny everyone. "*" means allow everyone.
fn is_user_allowed(&self, user_id: &str) -> bool {
@ -53,7 +55,7 @@ impl MattermostChannel {
/// and detect @-mentions by username.
async fn get_bot_identity(&self) -> (String, String) {
let resp: Option<serde_json::Value> = async {
self.client
self.http_client()
.get(format!("{}/api/v4/users/me", self.base_url))
.bearer_auth(&self.bot_token)
.send()
@ -109,7 +111,7 @@ impl Channel for MattermostChannel {
}
let resp = self
.client
.http_client()
.post(format!("{}/api/v4/posts", self.base_url))
.bearer_auth(&self.bot_token)
.json(&body_map)
@ -147,7 +149,7 @@ impl Channel for MattermostChannel {
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let resp = match self
.client
.http_client()
.get(format!(
"{}/api/v4/channels/{}/posts",
self.base_url, channel_id
@ -202,7 +204,7 @@ impl Channel for MattermostChannel {
}
async fn health_check(&self) -> bool {
self.client
self.http_client()
.get(format!("{}/api/v4/users/me", self.base_url))
.bearer_auth(&self.bot_token)
.send()
@ -215,7 +217,7 @@ impl Channel for MattermostChannel {
// Cancel any existing typing loop before starting a new one.
self.stop_typing(recipient).await?;
let client = self.client.clone();
let client = self.http_client();
let token = self.bot_token.clone();
let base_url = self.base_url.clone();

View file

@ -20,7 +20,6 @@ pub struct QQChannel {
app_id: String,
app_secret: String,
allowed_users: Vec<String>,
client: reqwest::Client,
/// Cached access token + expiry timestamp.
token_cache: Arc<RwLock<Option<(String, u64)>>>,
/// Message deduplication set.
@ -33,12 +32,15 @@ impl QQChannel {
app_id,
app_secret,
allowed_users,
client: reqwest::Client::new(),
token_cache: Arc::new(RwLock::new(None)),
dedup: Arc::new(RwLock::new(HashSet::new())),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.qq")
}
fn is_user_allowed(&self, user_id: &str) -> bool {
self.allowed_users.iter().any(|u| u == "*" || u == user_id)
}
@ -50,7 +52,12 @@ impl QQChannel {
"clientSecret": self.app_secret,
});
let resp = self.client.post(QQ_AUTH_URL).json(&body).send().await?;
let resp = self
.http_client()
.post(QQ_AUTH_URL)
.json(&body)
.send()
.await?;
if !resp.status().is_success() {
let status = resp.status();
@ -109,7 +116,7 @@ impl QQChannel {
/// Get the WebSocket gateway URL.
async fn get_gateway_url(&self, token: &str) -> anyhow::Result<String> {
let resp = self
.client
.http_client()
.get(format!("{QQ_API_BASE}/gateway"))
.header("Authorization", format!("QQBot {token}"))
.send()
@ -190,7 +197,7 @@ impl Channel for QQChannel {
};
let resp = self
.client
.http_client()
.post(&url)
.header("Authorization", format!("QQBot {token}"))
.json(&body)

View file

@ -28,7 +28,6 @@ pub struct SignalChannel {
allowed_from: Vec<String>,
ignore_attachments: bool,
ignore_stories: bool,
client: Client,
}
// ── signal-cli SSE event JSON shapes ────────────────────────────
@ -81,10 +80,6 @@ impl SignalChannel {
ignore_stories: bool,
) -> Self {
let http_url = http_url.trim_end_matches('/').to_string();
let client = Client::builder()
.connect_timeout(Duration::from_secs(10))
.build()
.expect("Signal HTTP client should build");
Self {
http_url,
account,
@ -92,10 +87,15 @@ impl SignalChannel {
allowed_from,
ignore_attachments,
ignore_stories,
client,
}
}
fn http_client(&self) -> Client {
let builder = Client::builder().connect_timeout(Duration::from_secs(10));
let builder = crate::config::apply_runtime_proxy_to_builder(builder, "channel.signal");
builder.build().expect("Signal HTTP client should build")
}
/// Effective sender: prefer `sourceNumber` (E.164), fall back to `source`.
fn sender(envelope: &Envelope) -> Option<String> {
envelope
@ -178,7 +178,7 @@ impl SignalChannel {
});
let resp = self
.client
.http_client()
.post(&url)
.timeout(Duration::from_secs(30))
.header("Content-Type", "application/json")
@ -298,7 +298,7 @@ impl Channel for SignalChannel {
loop {
let resp = self
.client
.http_client()
.get(url.clone())
.header("Accept", "text/event-stream")
.send()
@ -408,7 +408,7 @@ impl Channel for SignalChannel {
async fn health_check(&self) -> bool {
let url = format!("{}/api/v1/check", self.http_url);
let Ok(resp) = self
.client
.http_client()
.get(&url)
.timeout(Duration::from_secs(10))
.send()

View file

@ -6,7 +6,6 @@ pub struct SlackChannel {
bot_token: String,
channel_id: Option<String>,
allowed_users: Vec<String>,
client: reqwest::Client,
}
impl SlackChannel {
@ -15,10 +14,13 @@ impl SlackChannel {
bot_token,
channel_id,
allowed_users,
client: reqwest::Client::new(),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.slack")
}
/// Check if a Slack user ID is in the allowlist.
/// Empty list means deny everyone until explicitly configured.
/// `"*"` means allow everyone.
@ -29,7 +31,7 @@ impl SlackChannel {
/// Get the bot's own user ID so we can ignore our own messages
async fn get_bot_user_id(&self) -> Option<String> {
let resp: serde_json::Value = self
.client
.http_client()
.get("https://slack.com/api/auth.test")
.bearer_auth(&self.bot_token)
.send()
@ -58,7 +60,7 @@ impl Channel for SlackChannel {
});
let resp = self
.client
.http_client()
.post("https://slack.com/api/chat.postMessage")
.bearer_auth(&self.bot_token)
.json(&body)
@ -108,7 +110,7 @@ impl Channel for SlackChannel {
}
let resp = match self
.client
.http_client()
.get("https://slack.com/api/conversations.history")
.bearer_auth(&self.bot_token)
.query(&params)
@ -179,7 +181,7 @@ impl Channel for SlackChannel {
}
async fn health_check(&self) -> bool {
self.client
self.http_client()
.get("https://slack.com/api/auth.test")
.bearer_auth(&self.bot_token)
.send()

View file

@ -357,6 +357,10 @@ impl TelegramChannel {
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.telegram")
}
fn normalize_identity(value: &str) -> String {
value.trim().trim_start_matches('@').to_string()
}
@ -448,7 +452,7 @@ impl TelegramChannel {
}
async fn fetch_bot_username(&self) -> anyhow::Result<String> {
let resp = self.client.get(self.api_url("getMe")).send().await?;
let resp = self.http_client().get(self.api_url("getMe")).send().await?;
if !resp.status().is_success() {
anyhow::bail!("Failed to fetch bot info: {}", resp.status());
@ -857,7 +861,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let markdown_resp = self
.client
.http_client()
.post(self.api_url("sendMessage"))
.json(&markdown_body)
.send()
@ -887,7 +891,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
}
let plain_resp = self
.client
.http_client()
.post(self.api_url("sendMessage"))
.json(&plain_body)
.send()
@ -936,7 +940,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url(method))
.json(&body)
.send()
@ -1029,7 +1033,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendDocument"))
.multipart(form)
.send()
@ -1068,7 +1072,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendDocument"))
.multipart(form)
.send()
@ -1112,7 +1116,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendPhoto"))
.multipart(form)
.send()
@ -1151,7 +1155,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendPhoto"))
.multipart(form)
.send()
@ -1195,7 +1199,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendVideo"))
.multipart(form)
.send()
@ -1239,7 +1243,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendAudio"))
.multipart(form)
.send()
@ -1283,7 +1287,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendVoice"))
.multipart(form)
.send()
@ -1320,7 +1324,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendDocument"))
.json(&body)
.send()
@ -1357,7 +1361,7 @@ Allowlist Telegram username (without '@') or numeric user ID.",
}
let resp = self
.client
.http_client()
.post(self.api_url("sendPhoto"))
.json(&body)
.send()
@ -1685,7 +1689,7 @@ impl Channel for TelegramChannel {
"allowed_updates": ["message"]
});
let resp = match self.client.post(&url).json(&body).send().await {
let resp = match self.http_client().post(&url).json(&body).send().await {
Ok(r) => r,
Err(e) => {
tracing::warn!("Telegram poll error: {e}");
@ -1750,7 +1754,7 @@ Ensure only one `zeroclaw` process is using this bot token."
"action": "typing"
});
let _ = self
.client
.http_client()
.post(self.api_url("sendChatAction"))
.json(&typing_body)
.send()
@ -1769,7 +1773,7 @@ Ensure only one `zeroclaw` process is using this bot token."
match tokio::time::timeout(
timeout_duration,
self.client.get(self.api_url("getMe")).send(),
self.http_client().get(self.api_url("getMe")).send(),
)
.await
{
@ -1788,7 +1792,7 @@ Ensure only one `zeroclaw` process is using this bot token."
async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
self.stop_typing(recipient).await?;
let client = self.client.clone();
let client = self.http_client();
let url = self.api_url("sendChatAction");
let chat_id = recipient.to_string();

View file

@ -13,7 +13,6 @@ pub struct WhatsAppChannel {
endpoint_id: String,
verify_token: String,
allowed_numbers: Vec<String>,
client: reqwest::Client,
}
impl WhatsAppChannel {
@ -28,10 +27,13 @@ impl WhatsAppChannel {
endpoint_id,
verify_token,
allowed_numbers,
client: reqwest::Client::new(),
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("channel.whatsapp")
}
/// Check if a phone number is allowed (E.164 format: +1234567890)
fn is_number_allowed(&self, phone: &str) -> bool {
self.allowed_numbers.iter().any(|n| n == "*" || n == phone)
@ -164,7 +166,7 @@ impl Channel for WhatsAppChannel {
});
let resp = self
.client
.http_client()
.post(&url)
.bearer_auth(&self.access_token)
.header("Content-Type", "application/json")
@ -201,7 +203,7 @@ impl Channel for WhatsAppChannel {
// Check if we can reach the WhatsApp API
let url = format!("https://graph.facebook.com/v18.0/{}", self.endpoint_id);
self.client
self.http_client()
.get(&url)
.bearer_auth(&self.access_token)
.send()

View file

@ -2,16 +2,18 @@ pub mod schema;
#[allow(unused_imports)]
pub use schema::{
apply_runtime_proxy_to_builder, build_runtime_proxy_client,
build_runtime_proxy_client_with_timeouts, runtime_proxy_config, set_runtime_proxy_config,
AgentConfig, AuditConfig, AutonomyConfig, BrowserComputerUseConfig, BrowserConfig,
ChannelsConfig, ClassificationRule, ComposioConfig, Config, CostConfig, CronConfig,
DelegateAgentConfig, DiscordConfig, DockerRuntimeConfig, GatewayConfig, HardwareConfig,
HardwareTransport, HeartbeatConfig, HttpRequestConfig, IMessageConfig, IdentityConfig,
LarkConfig, MatrixConfig, MemoryConfig, ModelRouteConfig, ObservabilityConfig,
PeripheralBoardConfig, PeripheralsConfig, QueryClassificationConfig, ReliabilityConfig,
ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig, SchedulerConfig,
SecretsConfig, SecurityConfig, SlackConfig, StorageConfig, StorageProviderConfig,
StorageProviderSection, StreamMode, TelegramConfig, TunnelConfig, WebSearchConfig,
WebhookConfig,
PeripheralBoardConfig, PeripheralsConfig, ProxyConfig, ProxyScope, QueryClassificationConfig,
ReliabilityConfig, ResourceLimitsConfig, RuntimeConfig, SandboxBackend, SandboxConfig,
SchedulerConfig, SecretsConfig, SecurityConfig, SlackConfig, StorageConfig,
StorageProviderConfig, StorageProviderSection, StreamMode, TelegramConfig, TunnelConfig,
WebSearchConfig, WebhookConfig,
};
#[cfg(test)]

View file

@ -7,6 +7,41 @@ use std::collections::HashMap;
use std::fs::{self, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::{OnceLock, RwLock};
const SUPPORTED_PROXY_SERVICE_KEYS: &[&str] = &[
"provider.anthropic",
"provider.compatible",
"provider.copilot",
"provider.gemini",
"provider.glm",
"provider.ollama",
"provider.openai",
"provider.openrouter",
"channel.dingtalk",
"channel.discord",
"channel.lark",
"channel.matrix",
"channel.mattermost",
"channel.qq",
"channel.signal",
"channel.slack",
"channel.telegram",
"channel.whatsapp",
"tool.browser",
"tool.composio",
"tool.http_request",
"tool.pushover",
"memory.embeddings",
"tunnel.custom",
];
const SUPPORTED_PROXY_SERVICE_SELECTORS: &[&str] =
&["provider.*", "channel.*", "tool.*", "memory.*", "tunnel.*"];
static RUNTIME_PROXY_CONFIG: OnceLock<RwLock<ProxyConfig>> = OnceLock::new();
static RUNTIME_PROXY_CLIENT_CACHE: OnceLock<RwLock<HashMap<String, reqwest::Client>>> =
OnceLock::new();
// ── Top-level config ──────────────────────────────────────────────
@ -87,6 +122,9 @@ pub struct Config {
#[serde(default)]
pub web_search: WebSearchConfig,
#[serde(default)]
pub proxy: ProxyConfig,
#[serde(default)]
pub identity: IdentityConfig,
@ -772,6 +810,465 @@ impl Default for WebSearchConfig {
}
}
// ── Proxy ───────────────────────────────────────────────────────
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ProxyScope {
Environment,
#[default]
Zeroclaw,
Services,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProxyConfig {
/// Enable proxy support for selected scope.
#[serde(default)]
pub enabled: bool,
/// Proxy URL for HTTP requests (supports http, https, socks5, socks5h).
#[serde(default)]
pub http_proxy: Option<String>,
/// Proxy URL for HTTPS requests (supports http, https, socks5, socks5h).
#[serde(default)]
pub https_proxy: Option<String>,
/// Fallback proxy URL for all schemes.
#[serde(default)]
pub all_proxy: Option<String>,
/// No-proxy bypass list. Same format as NO_PROXY.
#[serde(default)]
pub no_proxy: Vec<String>,
/// Proxy application scope.
#[serde(default)]
pub scope: ProxyScope,
/// Service selectors used when scope = "services".
#[serde(default)]
pub services: Vec<String>,
}
impl Default for ProxyConfig {
fn default() -> Self {
Self {
enabled: false,
http_proxy: None,
https_proxy: None,
all_proxy: None,
no_proxy: Vec::new(),
scope: ProxyScope::Zeroclaw,
services: Vec::new(),
}
}
}
impl ProxyConfig {
pub fn supported_service_keys() -> &'static [&'static str] {
SUPPORTED_PROXY_SERVICE_KEYS
}
pub fn supported_service_selectors() -> &'static [&'static str] {
SUPPORTED_PROXY_SERVICE_SELECTORS
}
pub fn has_any_proxy_url(&self) -> bool {
normalize_proxy_url_option(self.http_proxy.as_deref()).is_some()
|| normalize_proxy_url_option(self.https_proxy.as_deref()).is_some()
|| normalize_proxy_url_option(self.all_proxy.as_deref()).is_some()
}
pub fn normalized_services(&self) -> Vec<String> {
normalize_service_list(self.services.clone())
}
pub fn normalized_no_proxy(&self) -> Vec<String> {
normalize_no_proxy_list(self.no_proxy.clone())
}
pub fn validate(&self) -> Result<()> {
for (field, value) in [
("http_proxy", self.http_proxy.as_deref()),
("https_proxy", self.https_proxy.as_deref()),
("all_proxy", self.all_proxy.as_deref()),
] {
if let Some(url) = normalize_proxy_url_option(value) {
validate_proxy_url(field, &url)?;
}
}
for selector in self.normalized_services() {
if !is_supported_proxy_service_selector(&selector) {
anyhow::bail!(
"Unsupported proxy service selector '{selector}'. Use tool `proxy_config` action `list_services` for valid values"
);
}
}
if self.enabled && !self.has_any_proxy_url() {
anyhow::bail!(
"Proxy is enabled but no proxy URL is configured. Set at least one of http_proxy, https_proxy, or all_proxy"
);
}
if self.enabled
&& self.scope == ProxyScope::Services
&& self.normalized_services().is_empty()
{
anyhow::bail!(
"proxy.scope='services' requires a non-empty proxy.services list when proxy is enabled"
);
}
Ok(())
}
pub fn should_apply_to_service(&self, service_key: &str) -> bool {
if !self.enabled {
return false;
}
match self.scope {
ProxyScope::Environment => false,
ProxyScope::Zeroclaw => true,
ProxyScope::Services => {
let service_key = service_key.trim().to_ascii_lowercase();
if service_key.is_empty() {
return false;
}
self.normalized_services()
.iter()
.any(|selector| service_selector_matches(selector, &service_key))
}
}
}
pub fn apply_to_reqwest_builder(
&self,
mut builder: reqwest::ClientBuilder,
service_key: &str,
) -> reqwest::ClientBuilder {
if !self.should_apply_to_service(service_key) {
return builder;
}
let no_proxy = self.no_proxy_value();
if let Some(url) = normalize_proxy_url_option(self.all_proxy.as_deref()) {
match reqwest::Proxy::all(&url) {
Ok(proxy) => {
builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
}
Err(error) => {
tracing::warn!(
proxy_url = %url,
service_key,
"Ignoring invalid all_proxy URL: {error}"
);
}
}
}
if let Some(url) = normalize_proxy_url_option(self.http_proxy.as_deref()) {
match reqwest::Proxy::http(&url) {
Ok(proxy) => {
builder = builder.proxy(apply_no_proxy(proxy, no_proxy.clone()));
}
Err(error) => {
tracing::warn!(
proxy_url = %url,
service_key,
"Ignoring invalid http_proxy URL: {error}"
);
}
}
}
if let Some(url) = normalize_proxy_url_option(self.https_proxy.as_deref()) {
match reqwest::Proxy::https(&url) {
Ok(proxy) => {
builder = builder.proxy(apply_no_proxy(proxy, no_proxy));
}
Err(error) => {
tracing::warn!(
proxy_url = %url,
service_key,
"Ignoring invalid https_proxy URL: {error}"
);
}
}
}
builder
}
pub fn apply_to_process_env(&self) {
set_proxy_env_pair("HTTP_PROXY", self.http_proxy.as_deref());
set_proxy_env_pair("HTTPS_PROXY", self.https_proxy.as_deref());
set_proxy_env_pair("ALL_PROXY", self.all_proxy.as_deref());
let no_proxy_joined = {
let list = self.normalized_no_proxy();
(!list.is_empty()).then(|| list.join(","))
};
set_proxy_env_pair("NO_PROXY", no_proxy_joined.as_deref());
}
pub fn clear_process_env() {
clear_proxy_env_pair("HTTP_PROXY");
clear_proxy_env_pair("HTTPS_PROXY");
clear_proxy_env_pair("ALL_PROXY");
clear_proxy_env_pair("NO_PROXY");
}
fn no_proxy_value(&self) -> Option<reqwest::NoProxy> {
let joined = {
let list = self.normalized_no_proxy();
(!list.is_empty()).then(|| list.join(","))
};
joined.as_deref().and_then(reqwest::NoProxy::from_string)
}
}
fn apply_no_proxy(proxy: reqwest::Proxy, no_proxy: Option<reqwest::NoProxy>) -> reqwest::Proxy {
proxy.no_proxy(no_proxy)
}
fn normalize_proxy_url_option(raw: Option<&str>) -> Option<String> {
let value = raw?.trim();
(!value.is_empty()).then(|| value.to_string())
}
fn normalize_no_proxy_list(values: Vec<String>) -> Vec<String> {
normalize_comma_values(values)
}
fn normalize_service_list(values: Vec<String>) -> Vec<String> {
let mut normalized = normalize_comma_values(values)
.into_iter()
.map(|value| value.to_ascii_lowercase())
.collect::<Vec<_>>();
normalized.sort_unstable();
normalized.dedup();
normalized
}
fn normalize_comma_values(values: Vec<String>) -> Vec<String> {
let mut output = Vec::new();
for value in values {
for part in value.split(',') {
let normalized = part.trim();
if normalized.is_empty() {
continue;
}
output.push(normalized.to_string());
}
}
output.sort_unstable();
output.dedup();
output
}
fn is_supported_proxy_service_selector(selector: &str) -> bool {
if SUPPORTED_PROXY_SERVICE_KEYS
.iter()
.any(|known| known.eq_ignore_ascii_case(selector))
{
return true;
}
SUPPORTED_PROXY_SERVICE_SELECTORS
.iter()
.any(|known| known.eq_ignore_ascii_case(selector))
}
fn service_selector_matches(selector: &str, service_key: &str) -> bool {
if selector == service_key {
return true;
}
if let Some(prefix) = selector.strip_suffix(".*") {
return service_key.starts_with(prefix)
&& service_key
.strip_prefix(prefix)
.is_some_and(|suffix| suffix.starts_with('.'));
}
false
}
fn validate_proxy_url(field: &str, url: &str) -> Result<()> {
let parsed = reqwest::Url::parse(url)
.with_context(|| format!("Invalid {field} URL: '{url}' is not a valid URL"))?;
match parsed.scheme() {
"http" | "https" | "socks5" | "socks5h" => {}
scheme => {
anyhow::bail!(
"Invalid {field} URL scheme '{scheme}'. Allowed: http, https, socks5, socks5h"
);
}
}
if parsed.host_str().is_none() {
anyhow::bail!("Invalid {field} URL: host is required");
}
Ok(())
}
fn set_proxy_env_pair(key: &str, value: Option<&str>) {
let lowercase_key = key.to_ascii_lowercase();
if let Some(value) = value.and_then(|candidate| normalize_proxy_url_option(Some(candidate))) {
std::env::set_var(key, &value);
std::env::set_var(lowercase_key, value);
} else {
std::env::remove_var(key);
std::env::remove_var(lowercase_key);
}
}
fn clear_proxy_env_pair(key: &str) {
std::env::remove_var(key);
std::env::remove_var(key.to_ascii_lowercase());
}
fn runtime_proxy_state() -> &'static RwLock<ProxyConfig> {
RUNTIME_PROXY_CONFIG.get_or_init(|| RwLock::new(ProxyConfig::default()))
}
fn runtime_proxy_client_cache() -> &'static RwLock<HashMap<String, reqwest::Client>> {
RUNTIME_PROXY_CLIENT_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
}
fn clear_runtime_proxy_client_cache() {
match runtime_proxy_client_cache().write() {
Ok(mut guard) => {
guard.clear();
}
Err(poisoned) => {
poisoned.into_inner().clear();
}
}
}
fn runtime_proxy_cache_key(
service_key: &str,
timeout_secs: Option<u64>,
connect_timeout_secs: Option<u64>,
) -> String {
format!(
"{}|timeout={}|connect_timeout={}",
service_key.trim().to_ascii_lowercase(),
timeout_secs
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string()),
connect_timeout_secs
.map(|value| value.to_string())
.unwrap_or_else(|| "none".to_string())
)
}
fn runtime_proxy_cached_client(cache_key: &str) -> Option<reqwest::Client> {
match runtime_proxy_client_cache().read() {
Ok(guard) => guard.get(cache_key).cloned(),
Err(poisoned) => poisoned.into_inner().get(cache_key).cloned(),
}
}
fn set_runtime_proxy_cached_client(cache_key: String, client: reqwest::Client) {
match runtime_proxy_client_cache().write() {
Ok(mut guard) => {
guard.insert(cache_key, client);
}
Err(poisoned) => {
poisoned.into_inner().insert(cache_key, client);
}
}
}
pub fn set_runtime_proxy_config(config: ProxyConfig) {
match runtime_proxy_state().write() {
Ok(mut guard) => {
*guard = config;
}
Err(poisoned) => {
*poisoned.into_inner() = config;
}
}
clear_runtime_proxy_client_cache();
}
pub fn runtime_proxy_config() -> ProxyConfig {
match runtime_proxy_state().read() {
Ok(guard) => guard.clone(),
Err(poisoned) => poisoned.into_inner().clone(),
}
}
pub fn apply_runtime_proxy_to_builder(
builder: reqwest::ClientBuilder,
service_key: &str,
) -> reqwest::ClientBuilder {
runtime_proxy_config().apply_to_reqwest_builder(builder, service_key)
}
pub fn build_runtime_proxy_client(service_key: &str) -> reqwest::Client {
let cache_key = runtime_proxy_cache_key(service_key, None, None);
if let Some(client) = runtime_proxy_cached_client(&cache_key) {
return client;
}
let builder = apply_runtime_proxy_to_builder(reqwest::Client::builder(), service_key);
let client = builder.build().unwrap_or_else(|error| {
tracing::warn!(service_key, "Failed to build proxied client: {error}");
reqwest::Client::new()
});
set_runtime_proxy_cached_client(cache_key, client.clone());
client
}
pub fn build_runtime_proxy_client_with_timeouts(
service_key: &str,
timeout_secs: u64,
connect_timeout_secs: u64,
) -> reqwest::Client {
let cache_key =
runtime_proxy_cache_key(service_key, Some(timeout_secs), Some(connect_timeout_secs));
if let Some(client) = runtime_proxy_cached_client(&cache_key) {
return client;
}
let builder = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(timeout_secs))
.connect_timeout(std::time::Duration::from_secs(connect_timeout_secs));
let builder = apply_runtime_proxy_to_builder(builder, service_key);
let client = builder.build().unwrap_or_else(|error| {
tracing::warn!(
service_key,
"Failed to build proxied timeout client: {error}"
);
reqwest::Client::new()
});
set_runtime_proxy_cached_client(cache_key, client.clone());
client
}
fn parse_proxy_scope(raw: &str) -> Option<ProxyScope> {
match raw.trim().to_ascii_lowercase().as_str() {
"environment" | "env" => Some(ProxyScope::Environment),
"zeroclaw" | "internal" | "core" => Some(ProxyScope::Zeroclaw),
"services" | "service" => Some(ProxyScope::Services),
_ => None,
}
}
fn parse_proxy_enabled(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"1" | "true" | "yes" | "on" => Some(true),
"0" | "false" | "no" | "off" => Some(false),
_ => None,
}
}
// ── Memory ───────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
@ -1922,6 +2419,7 @@ impl Default for Config {
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
web_search: WebSearchConfig::default(),
proxy: ProxyConfig::default(),
identity: IdentityConfig::default(),
cost: CostConfig::default(),
peripherals: PeripheralsConfig::default(),
@ -2368,6 +2866,74 @@ impl Config {
}
}
}
// Proxy enabled flag: ZEROCLAW_PROXY_ENABLED
let explicit_proxy_enabled = std::env::var("ZEROCLAW_PROXY_ENABLED")
.ok()
.as_deref()
.and_then(parse_proxy_enabled);
if let Some(enabled) = explicit_proxy_enabled {
self.proxy.enabled = enabled;
}
// Proxy URLs: ZEROCLAW_* wins, then generic *PROXY vars.
let mut proxy_url_overridden = false;
if let Ok(proxy_url) =
std::env::var("ZEROCLAW_HTTP_PROXY").or_else(|_| std::env::var("HTTP_PROXY"))
{
self.proxy.http_proxy = normalize_proxy_url_option(Some(&proxy_url));
proxy_url_overridden = true;
}
if let Ok(proxy_url) =
std::env::var("ZEROCLAW_HTTPS_PROXY").or_else(|_| std::env::var("HTTPS_PROXY"))
{
self.proxy.https_proxy = normalize_proxy_url_option(Some(&proxy_url));
proxy_url_overridden = true;
}
if let Ok(proxy_url) =
std::env::var("ZEROCLAW_ALL_PROXY").or_else(|_| std::env::var("ALL_PROXY"))
{
self.proxy.all_proxy = normalize_proxy_url_option(Some(&proxy_url));
proxy_url_overridden = true;
}
if let Ok(no_proxy) =
std::env::var("ZEROCLAW_NO_PROXY").or_else(|_| std::env::var("NO_PROXY"))
{
self.proxy.no_proxy = normalize_no_proxy_list(vec![no_proxy]);
}
if explicit_proxy_enabled.is_none()
&& proxy_url_overridden
&& self.proxy.has_any_proxy_url()
{
self.proxy.enabled = true;
}
// Proxy scope and service selectors.
if let Ok(scope_raw) = std::env::var("ZEROCLAW_PROXY_SCOPE") {
if let Some(scope) = parse_proxy_scope(&scope_raw) {
self.proxy.scope = scope;
} else {
tracing::warn!(
scope = %scope_raw,
"Ignoring invalid ZEROCLAW_PROXY_SCOPE (valid: environment|zeroclaw|services)"
);
}
}
if let Ok(services_raw) = std::env::var("ZEROCLAW_PROXY_SERVICES") {
self.proxy.services = normalize_service_list(vec![services_raw]);
}
if let Err(error) = self.proxy.validate() {
tracing::warn!("Invalid proxy configuration ignored: {error}");
self.proxy.enabled = false;
}
if self.proxy.enabled && self.proxy.scope == ProxyScope::Environment {
self.proxy.apply_to_process_env();
}
set_runtime_proxy_config(self.proxy.clone());
}
pub fn save(&self) -> Result<()> {
@ -2682,6 +3248,7 @@ default_temperature = 0.7
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
web_search: WebSearchConfig::default(),
proxy: ProxyConfig::default(),
agent: AgentConfig::default(),
identity: IdentityConfig::default(),
cost: CostConfig::default(),
@ -2821,6 +3388,7 @@ tool_dispatcher = "xml"
browser: BrowserConfig::default(),
http_request: HttpRequestConfig::default(),
web_search: WebSearchConfig::default(),
proxy: ProxyConfig::default(),
agent: AgentConfig::default(),
identity: IdentityConfig::default(),
cost: CostConfig::default(),
@ -3619,6 +4187,28 @@ default_temperature = 0.7
.expect("env override test lock poisoned")
}
fn clear_proxy_env_test_vars() {
for key in [
"ZEROCLAW_PROXY_ENABLED",
"ZEROCLAW_HTTP_PROXY",
"ZEROCLAW_HTTPS_PROXY",
"ZEROCLAW_ALL_PROXY",
"ZEROCLAW_NO_PROXY",
"ZEROCLAW_PROXY_SCOPE",
"ZEROCLAW_PROXY_SERVICES",
"HTTP_PROXY",
"HTTPS_PROXY",
"ALL_PROXY",
"NO_PROXY",
"http_proxy",
"https_proxy",
"all_proxy",
"no_proxy",
] {
std::env::remove_var(key);
}
}
#[test]
fn env_override_api_key() {
let _env_guard = env_override_test_guard();
@ -4108,6 +4698,128 @@ default_model = "legacy-model"
std::env::remove_var("ZEROCLAW_STORAGE_CONNECT_TIMEOUT_SECS");
}
#[test]
fn proxy_config_scope_services_requires_entries_when_enabled() {
let proxy = ProxyConfig {
enabled: true,
http_proxy: Some("http://127.0.0.1:7890".into()),
https_proxy: None,
all_proxy: None,
no_proxy: Vec::new(),
scope: ProxyScope::Services,
services: Vec::new(),
};
let error = proxy.validate().unwrap_err().to_string();
assert!(error.contains("proxy.scope='services'"));
}
#[test]
fn env_override_proxy_scope_services() {
let _env_guard = env_override_test_guard();
clear_proxy_env_test_vars();
let mut config = Config::default();
std::env::set_var("ZEROCLAW_PROXY_ENABLED", "true");
std::env::set_var("ZEROCLAW_HTTP_PROXY", "http://127.0.0.1:7890");
std::env::set_var(
"ZEROCLAW_PROXY_SERVICES",
"provider.openai, tool.http_request",
);
std::env::set_var("ZEROCLAW_PROXY_SCOPE", "services");
config.apply_env_overrides();
assert!(config.proxy.enabled);
assert_eq!(config.proxy.scope, ProxyScope::Services);
assert_eq!(
config.proxy.http_proxy.as_deref(),
Some("http://127.0.0.1:7890")
);
assert!(config.proxy.should_apply_to_service("provider.openai"));
assert!(config.proxy.should_apply_to_service("tool.http_request"));
assert!(!config.proxy.should_apply_to_service("provider.anthropic"));
clear_proxy_env_test_vars();
}
#[test]
fn env_override_proxy_scope_environment_applies_process_env() {
let _env_guard = env_override_test_guard();
clear_proxy_env_test_vars();
let mut config = Config::default();
std::env::set_var("ZEROCLAW_PROXY_ENABLED", "true");
std::env::set_var("ZEROCLAW_PROXY_SCOPE", "environment");
std::env::set_var("ZEROCLAW_HTTP_PROXY", "http://127.0.0.1:7890");
std::env::set_var("ZEROCLAW_HTTPS_PROXY", "http://127.0.0.1:7891");
std::env::set_var("ZEROCLAW_NO_PROXY", "localhost,127.0.0.1");
config.apply_env_overrides();
assert_eq!(config.proxy.scope, ProxyScope::Environment);
assert_eq!(
std::env::var("HTTP_PROXY").ok().as_deref(),
Some("http://127.0.0.1:7890")
);
assert_eq!(
std::env::var("HTTPS_PROXY").ok().as_deref(),
Some("http://127.0.0.1:7891")
);
assert!(std::env::var("NO_PROXY")
.ok()
.is_some_and(|value| value.contains("localhost")));
clear_proxy_env_test_vars();
}
fn runtime_proxy_cache_contains(cache_key: &str) -> bool {
match runtime_proxy_client_cache().read() {
Ok(guard) => guard.contains_key(cache_key),
Err(poisoned) => poisoned.into_inner().contains_key(cache_key),
}
}
#[test]
fn runtime_proxy_client_cache_reuses_default_profile_key() {
let service_key = format!(
"provider.cache_test.{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos()
);
let cache_key = runtime_proxy_cache_key(&service_key, None, None);
clear_runtime_proxy_client_cache();
assert!(!runtime_proxy_cache_contains(&cache_key));
let _ = build_runtime_proxy_client(&service_key);
assert!(runtime_proxy_cache_contains(&cache_key));
let _ = build_runtime_proxy_client(&service_key);
assert!(runtime_proxy_cache_contains(&cache_key));
}
#[test]
fn set_runtime_proxy_config_clears_runtime_proxy_client_cache() {
let service_key = format!(
"provider.cache_timeout_test.{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after unix epoch")
.as_nanos()
);
let cache_key = runtime_proxy_cache_key(&service_key, Some(30), Some(5));
clear_runtime_proxy_client_cache();
let _ = build_runtime_proxy_client_with_timeouts(&service_key, 30, 5);
assert!(runtime_proxy_cache_contains(&cache_key));
set_runtime_proxy_config(ProxyConfig::default());
assert!(!runtime_proxy_cache_contains(&cache_key));
}
#[test]
fn gateway_config_default_values() {
let g = GatewayConfig::default();

View file

@ -43,7 +43,6 @@ impl EmbeddingProvider for NoopEmbedding {
// ── OpenAI-compatible embedding provider ─────────────────────
pub struct OpenAiEmbedding {
client: reqwest::Client,
base_url: String,
api_key: String,
model: String,
@ -53,7 +52,6 @@ pub struct OpenAiEmbedding {
impl OpenAiEmbedding {
pub fn new(base_url: &str, api_key: &str, model: &str, dims: usize) -> Self {
Self {
client: reqwest::Client::new(),
base_url: base_url.trim_end_matches('/').to_string(),
api_key: api_key.to_string(),
model: model.to_string(),
@ -61,6 +59,10 @@ impl OpenAiEmbedding {
}
}
fn http_client(&self) -> reqwest::Client {
crate::config::build_runtime_proxy_client("memory.embeddings")
}
fn has_explicit_api_path(&self) -> bool {
let Ok(url) = reqwest::Url::parse(&self.base_url) else {
return false;
@ -112,7 +114,7 @@ impl EmbeddingProvider for OpenAiEmbedding {
});
let resp = self
.client
.http_client()
.post(self.embeddings_url())
.header("Authorization", format!("Bearer {}", self.api_key))
.header("Content-Type", "application/json")

View file

@ -133,6 +133,7 @@ pub fn run_wizard() -> Result<Config> {
browser: BrowserConfig::default(),
http_request: crate::config::HttpRequestConfig::default(),
web_search: crate::config::WebSearchConfig::default(),
proxy: crate::config::ProxyConfig::default(),
identity: crate::config::IdentityConfig::default(),
cost: crate::config::CostConfig::default(),
peripherals: crate::config::PeripheralsConfig::default(),
@ -356,6 +357,7 @@ pub fn run_quick_setup(
browser: BrowserConfig::default(),
http_request: crate::config::HttpRequestConfig::default(),
web_search: crate::config::WebSearchConfig::default(),
proxy: crate::config::ProxyConfig::default(),
identity: crate::config::IdentityConfig::default(),
cost: crate::config::CostConfig::default(),
peripherals: crate::config::PeripheralsConfig::default(),

View file

@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize};
pub struct AnthropicProvider {
credential: Option<String>,
base_url: String,
client: Client,
}
#[derive(Debug, Serialize)]
@ -161,11 +160,6 @@ impl AnthropicProvider {
.filter(|k| !k.is_empty())
.map(ToString::to_string),
base_url,
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
@ -404,6 +398,10 @@ impl AnthropicProvider {
tool_calls,
}
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.anthropic", 120, 10)
}
}
#[async_trait]
@ -433,7 +431,7 @@ impl Provider for AnthropicProvider {
};
let mut request = self
.client
.http_client()
.post(format!("{}/v1/messages", self.base_url))
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
@ -480,7 +478,7 @@ impl Provider for AnthropicProvider {
};
let req = self
.client
.http_client()
.post(format!("{}/v1/messages", self.base_url))
.header("anthropic-version", "2023-06-01")
.header("content-type", "application/json")
@ -502,7 +500,7 @@ impl Provider for AnthropicProvider {
async fn warmup(&self) -> anyhow::Result<()> {
if let Some(credential) = self.credential.as_ref() {
let mut request = self
.client
.http_client()
.post(format!("{}/v1/messages", self.base_url))
.header("anthropic-version", "2023-06-01");
request = self.apply_auth(request, credential);
@ -594,7 +592,9 @@ mod tests {
let provider = AnthropicProvider::new(None);
let request = provider
.apply_auth(
provider.client.get("https://api.anthropic.com/v1/models"),
provider
.http_client()
.get("https://api.anthropic.com/v1/models"),
"sk-ant-oat01-test-token",
)
.build()
@ -622,7 +622,9 @@ mod tests {
let provider = AnthropicProvider::new(None);
let request = provider
.apply_auth(
provider.client.get("https://api.anthropic.com/v1/models"),
provider
.http_client()
.get("https://api.anthropic.com/v1/models"),
"sk-ant-api-key",
)
.build()

View file

@ -22,7 +22,6 @@ pub struct OpenAiCompatibleProvider {
/// When false, do not fall back to /v1/responses on chat completions 404.
/// GLM/Zhipu does not support the responses API.
supports_responses_fallback: bool,
client: Client,
}
/// How the provider expects the API key to be sent.
@ -49,11 +48,6 @@ impl OpenAiCompatibleProvider {
credential: credential.map(ToString::to_string),
auth_header: auth_style,
supports_responses_fallback: true,
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
@ -71,14 +65,13 @@ impl OpenAiCompatibleProvider {
credential: credential.map(ToString::to_string),
auth_header: auth_style,
supports_responses_fallback: false,
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.compatible", 120, 10)
}
/// Build the full URL for chat completions, detecting if base_url already includes the path.
/// This allows custom providers with non-standard endpoints (e.g., VolcEngine ARK uses
/// `/api/coding/v3/chat/completions` instead of `/v1/chat/completions`).
@ -513,7 +506,7 @@ impl OpenAiCompatibleProvider {
let url = self.responses_url();
let response = self
.apply_auth_header(self.client.post(&url).json(&request), credential)
.apply_auth_header(self.http_client().post(&url).json(&request), credential)
.send()
.await?;
@ -578,7 +571,7 @@ impl Provider for OpenAiCompatibleProvider {
let url = self.chat_completions_url();
let response = self
.apply_auth_header(self.client.post(&url).json(&request), credential)
.apply_auth_header(self.http_client().post(&url).json(&request), credential)
.send()
.await?;
@ -660,7 +653,7 @@ impl Provider for OpenAiCompatibleProvider {
let url = self.chat_completions_url();
let response = self
.apply_auth_header(self.client.post(&url).json(&request), credential)
.apply_auth_header(self.http_client().post(&url).json(&request), credential)
.send()
.await?;
@ -760,7 +753,7 @@ impl Provider for OpenAiCompatibleProvider {
let url = self.chat_completions_url();
let response = self
.apply_auth_header(self.client.post(&url).json(&request), credential)
.apply_auth_header(self.http_client().post(&url).json(&request), credential)
.send()
.await?;
@ -900,7 +893,7 @@ impl Provider for OpenAiCompatibleProvider {
};
let url = self.chat_completions_url();
let client = self.client.clone();
let client = self.http_client();
let auth_header = self.auth_header.clone();
// Use a channel to bridge the async HTTP response to the stream
@ -967,7 +960,7 @@ impl Provider for OpenAiCompatibleProvider {
// the goal is TLS handshake and HTTP/2 negotiation.
let url = self.chat_completions_url();
let _ = self
.apply_auth_header(self.client.get(&url), credential)
.apply_auth_header(self.http_client().get(&url), credential)
.send()
.await?;
}

View file

@ -161,7 +161,6 @@ pub struct CopilotProvider {
/// Mutex ensures only one caller refreshes tokens at a time,
/// preventing duplicate device flow prompts or redundant API calls.
refresh_lock: Arc<Mutex<Option<CachedApiKey>>>,
http: Client,
token_dir: PathBuf,
}
@ -204,15 +203,14 @@ impl CopilotProvider {
.filter(|token| !token.is_empty())
.map(String::from),
refresh_lock: Arc::new(Mutex::new(None)),
http: Client::builder()
.timeout(Duration::from_secs(120))
.connect_timeout(Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
token_dir,
}
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.copilot", 120, 10)
}
/// Required headers for Copilot API requests (editor identification).
const COPILOT_HEADERS: [(&str, &str); 4] = [
("Editor-Version", "vscode/1.85.1"),
@ -326,7 +324,7 @@ impl CopilotProvider {
};
let mut req = self
.http
.http_client()
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&request);
@ -438,7 +436,7 @@ impl CopilotProvider {
/// Run GitHub OAuth device code flow.
async fn device_code_login(&self) -> anyhow::Result<String> {
let response: DeviceCodeResponse = self
.http
.http_client()
.post(GITHUB_DEVICE_CODE_URL)
.header("Accept", "application/json")
.json(&serde_json::json!({
@ -467,7 +465,7 @@ impl CopilotProvider {
tokio::time::sleep(poll_interval).await;
let token_response: AccessTokenResponse = self
.http
.http_client()
.post(GITHUB_ACCESS_TOKEN_URL)
.header("Accept", "application/json")
.json(&serde_json::json!({
@ -502,7 +500,7 @@ impl CopilotProvider {
/// Exchange a GitHub access token for a Copilot API key.
async fn exchange_for_api_key(&self, access_token: &str) -> anyhow::Result<ApiKeyInfo> {
let mut request = self.http.get(GITHUB_API_KEY_URL);
let mut request = self.http_client().get(GITHUB_API_KEY_URL);
for (header, value) in &Self::COPILOT_HEADERS {
request = request.header(*header, *value);
}

View file

@ -13,7 +13,6 @@ use std::path::PathBuf;
/// Gemini provider supporting multiple authentication methods.
pub struct GeminiProvider {
auth: Option<GeminiAuth>,
client: Client,
}
/// Resolved credential — the variant determines both the HTTP auth method
@ -161,11 +160,6 @@ impl GeminiProvider {
Self {
auth: resolved_auth,
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
@ -279,6 +273,10 @@ impl GeminiProvider {
}
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.gemini", 120, 10)
}
fn build_generate_content_request(
&self,
auth: &GeminiAuth,
@ -286,6 +284,7 @@ impl GeminiProvider {
request: &GenerateContentRequest,
model: &str,
) -> reqwest::RequestBuilder {
let req = self.http_client().post(url).json(request);
match auth {
GeminiAuth::OAuthToken(token) => {
// Internal API expects the model in the request body envelope
@ -317,12 +316,12 @@ impl GeminiProvider {
.collect(),
}),
};
self.client
self.http_client()
.post(url)
.json(&internal_request)
.bearer_auth(token)
}
_ => self.client.post(url).json(request),
_ => req,
}
}
}
@ -408,7 +407,7 @@ impl Provider for GeminiProvider {
"https://generativelanguage.googleapis.com/v1beta/models".to_string()
};
let mut request = self.client.get(&url);
let mut request = self.http_client().get(&url);
if let GeminiAuth::OAuthToken(token) = auth {
request = request.bearer_auth(token);
}
@ -470,17 +469,13 @@ mod tests {
fn auth_source_explicit_key() {
let provider = GeminiProvider {
auth: Some(GeminiAuth::ExplicitKey("key".into())),
client: Client::new(),
};
assert_eq!(provider.auth_source(), "config");
}
#[test]
fn auth_source_none_without_credentials() {
let provider = GeminiProvider {
auth: None,
client: Client::new(),
};
let provider = GeminiProvider { auth: None };
assert_eq!(provider.auth_source(), "none");
}
@ -488,7 +483,6 @@ mod tests {
fn auth_source_oauth() {
let provider = GeminiProvider {
auth: Some(GeminiAuth::OAuthToken("ya29.mock".into())),
client: Client::new(),
};
assert_eq!(provider.auth_source(), "Gemini CLI OAuth");
}
@ -534,7 +528,6 @@ mod tests {
fn oauth_request_uses_bearer_auth_header() {
let provider = GeminiProvider {
auth: Some(GeminiAuth::OAuthToken("ya29.mock-token".into())),
client: Client::new(),
};
let auth = GeminiAuth::OAuthToken("ya29.mock-token".into());
let url = GeminiProvider::build_generate_content_url("gemini-2.0-flash", &auth);
@ -570,7 +563,6 @@ mod tests {
fn api_key_request_does_not_set_bearer_header() {
let provider = GeminiProvider {
auth: Some(GeminiAuth::ExplicitKey("api-key-123".into())),
client: Client::new(),
};
let auth = GeminiAuth::ExplicitKey("api-key-123".into());
let url = GeminiProvider::build_generate_content_url("gemini-2.0-flash", &auth);
@ -689,10 +681,7 @@ mod tests {
#[tokio::test]
async fn warmup_without_key_is_noop() {
let provider = GeminiProvider {
auth: None,
client: Client::new(),
};
let provider = GeminiProvider { auth: None };
let result = provider.warmup().await;
assert!(result.is_ok());
}

View file

@ -14,7 +14,6 @@ pub struct GlmProvider {
api_key_id: String,
api_key_secret: String,
base_url: String,
client: Client,
/// Cached JWT token + expiry timestamp (ms)
token_cache: Mutex<Option<(String, u64)>>,
}
@ -90,11 +89,6 @@ impl GlmProvider {
api_key_id: id,
api_key_secret: secret,
base_url: "https://api.z.ai/api/paas/v4".to_string(),
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
token_cache: Mutex::new(None),
}
}
@ -149,6 +143,10 @@ impl GlmProvider {
Ok(token)
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.glm", 120, 10)
}
}
#[async_trait]
@ -185,7 +183,7 @@ impl Provider for GlmProvider {
let url = format!("{}/chat/completions", self.base_url);
let response = self
.client
.http_client()
.post(&url)
.header("Authorization", format!("Bearer {token}"))
.json(&request)

View file

@ -6,7 +6,6 @@ use serde::{Deserialize, Serialize};
pub struct OllamaProvider {
base_url: String,
api_key: Option<String>,
client: Client,
}
// ─── Request Structures ───────────────────────────────────────────────────────
@ -76,11 +75,6 @@ impl OllamaProvider {
.trim_end_matches('/')
.to_string(),
api_key,
client: Client::builder()
.timeout(std::time::Duration::from_secs(300))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
@ -91,6 +85,10 @@ impl OllamaProvider {
.is_some_and(|host| matches!(host.as_str(), "localhost" | "127.0.0.1" | "::1"))
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.ollama", 300, 10)
}
fn resolve_request_details(&self, model: &str) -> anyhow::Result<(String, bool)> {
let requests_cloud = model.ends_with(":cloud");
let normalized_model = model.strip_suffix(":cloud").unwrap_or(model).to_string();
@ -139,7 +137,7 @@ impl OllamaProvider {
temperature
);
let mut request_builder = self.client.post(&url).json(&request);
let mut request_builder = self.http_client().post(&url).json(&request);
if should_auth {
if let Some(key) = self.api_key.as_ref() {

View file

@ -10,7 +10,6 @@ use serde::{Deserialize, Serialize};
pub struct OpenAiProvider {
base_url: String,
credential: Option<String>,
client: Client,
}
#[derive(Debug, Serialize)]
@ -148,11 +147,6 @@ impl OpenAiProvider {
.map(|u| u.trim_end_matches('/').to_string())
.unwrap_or_else(|| "https://api.openai.com/v1".to_string()),
credential: credential.map(ToString::to_string),
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
@ -254,6 +248,10 @@ impl OpenAiProvider {
ProviderChatResponse { text, tool_calls }
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.openai", 120, 10)
}
}
#[async_trait]
@ -290,7 +288,7 @@ impl Provider for OpenAiProvider {
};
let response = self
.client
.http_client()
.post(format!("{}/chat/completions", self.base_url))
.header("Authorization", format!("Bearer {credential}"))
.json(&request)
@ -331,7 +329,7 @@ impl Provider for OpenAiProvider {
};
let response = self
.client
.http_client()
.post(format!("{}/chat/completions", self.base_url))
.header("Authorization", format!("Bearer {credential}"))
.json(&native_request)
@ -358,7 +356,7 @@ impl Provider for OpenAiProvider {
async fn warmup(&self) -> anyhow::Result<()> {
if let Some(credential) = self.credential.as_ref() {
self.client
self.http_client()
.get(format!("{}/models", self.base_url))
.header("Authorization", format!("Bearer {credential}"))
.send()

View file

@ -9,7 +9,6 @@ use serde::{Deserialize, Serialize};
pub struct OpenRouterProvider {
credential: Option<String>,
client: Client,
}
#[derive(Debug, Serialize)]
@ -113,11 +112,6 @@ impl OpenRouterProvider {
pub fn new(credential: Option<&str>) -> Self {
Self {
credential: credential.map(ToString::to_string),
client: Client::builder()
.timeout(std::time::Duration::from_secs(120))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
@ -225,6 +219,10 @@ impl OpenRouterProvider {
tool_calls,
}
}
fn http_client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("provider.openrouter", 120, 10)
}
}
#[async_trait]
@ -233,7 +231,7 @@ impl Provider for OpenRouterProvider {
// Hit a lightweight endpoint to establish TLS + HTTP/2 connection pool.
// This prevents the first real chat request from timing out on cold start.
if let Some(credential) = self.credential.as_ref() {
self.client
self.http_client()
.get("https://openrouter.ai/api/v1/auth/key")
.header("Authorization", format!("Bearer {credential}"))
.send()
@ -274,7 +272,7 @@ impl Provider for OpenRouterProvider {
};
let response = self
.client
.http_client()
.post("https://openrouter.ai/api/v1/chat/completions")
.header("Authorization", format!("Bearer {credential}"))
.header(
@ -324,7 +322,7 @@ impl Provider for OpenRouterProvider {
};
let response = self
.client
.http_client()
.post("https://openrouter.ai/api/v1/chat/completions")
.header("Authorization", format!("Bearer {credential}"))
.header(
@ -372,7 +370,7 @@ impl Provider for OpenRouterProvider {
};
let response = self
.client
.http_client()
.post("https://openrouter.ai/api/v1/chat/completions")
.header("Authorization", format!("Bearer {credential}"))
.header(
@ -460,7 +458,7 @@ impl Provider for OpenRouterProvider {
};
let response = self
.client
.http_client()
.post("https://openrouter.ai/api/v1/chat/completions")
.header("Authorization", format!("Bearer {credential}"))
.header(

View file

@ -736,7 +736,7 @@ impl BrowserTool {
}
});
let client = reqwest::Client::new();
let client = crate::config::build_runtime_proxy_client("tool.browser");
let mut request = client
.post(endpoint)
.timeout(Duration::from_millis(self.computer_use.timeout_ms))

View file

@ -24,7 +24,6 @@ pub struct ComposioTool {
api_key: String,
default_entity_id: String,
security: Arc<SecurityPolicy>,
client: Client,
}
impl ComposioTool {
@ -37,14 +36,13 @@ impl ComposioTool {
api_key: api_key.to_string(),
default_entity_id: normalize_entity_id(default_entity_id.unwrap_or("default")),
security,
client: Client::builder()
.timeout(std::time::Duration::from_secs(60))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.unwrap_or_else(|_| Client::new()),
}
}
fn client(&self) -> Client {
crate::config::build_runtime_proxy_client_with_timeouts("tool.composio", 60, 10)
}
/// List available Composio apps/actions for the authenticated user.
///
/// Uses v3 endpoint first and falls back to v2 for compatibility.
@ -68,7 +66,7 @@ impl ComposioTool {
async fn list_actions_v3(&self, app_name: Option<&str>) -> anyhow::Result<Vec<ComposioAction>> {
let url = format!("{COMPOSIO_API_BASE_V3}/tools");
let mut req = self.client.get(&url).header("x-api-key", &self.api_key);
let mut req = self.client().get(&url).header("x-api-key", &self.api_key);
req = req.query(&[("limit", "200")]);
if let Some(app) = app_name.map(str::trim).filter(|app| !app.is_empty()) {
@ -95,7 +93,7 @@ impl ComposioTool {
}
let resp = self
.client
.client()
.get(&url)
.header("x-api-key", &self.api_key)
.send()
@ -180,7 +178,7 @@ impl ComposioTool {
);
let resp = self
.client
.client()
.post(&url)
.header("x-api-key", &self.api_key)
.json(&body)
@ -216,7 +214,7 @@ impl ComposioTool {
}
let resp = self
.client
.client()
.post(&url)
.header("x-api-key", &self.api_key)
.json(&body)
@ -288,7 +286,7 @@ impl ComposioTool {
});
let resp = self
.client
.client()
.post(&url)
.header("x-api-key", &self.api_key)
.json(&body)
@ -321,7 +319,7 @@ impl ComposioTool {
});
let resp = self
.client
.client()
.post(&url)
.header("x-api-key", &self.api_key)
.json(&body)
@ -345,7 +343,7 @@ impl ComposioTool {
let url = format!("{COMPOSIO_API_BASE_V3}/auth_configs");
let resp = self
.client
.client()
.get(&url)
.header("x-api-key", &self.api_key)
.query(&[

View file

@ -114,10 +114,12 @@ impl HttpRequestTool {
headers: Vec<(String, String)>,
body: Option<&str>,
) -> anyhow::Result<reqwest::Response> {
let client = reqwest::Client::builder()
let builder = reqwest::Client::builder()
.timeout(Duration::from_secs(self.timeout_secs))
.redirect(reqwest::redirect::Policy::none())
.build()?;
.connect_timeout(Duration::from_secs(10))
.redirect(reqwest::redirect::Policy::none());
let builder = crate::config::apply_runtime_proxy_to_builder(builder, "tool.http_request");
let client = builder.build()?;
let mut request = client.request(method, url);

View file

@ -19,6 +19,7 @@ pub mod image_info;
pub mod memory_forget;
pub mod memory_recall;
pub mod memory_store;
pub mod proxy_config;
pub mod pushover;
pub mod schedule;
pub mod schema;
@ -48,6 +49,7 @@ pub use image_info::ImageInfoTool;
pub use memory_forget::MemoryForgetTool;
pub use memory_recall::MemoryRecallTool;
pub use memory_store::MemoryStoreTool;
pub use proxy_config::ProxyConfigTool;
pub use pushover::PushoverTool;
pub use schedule::ScheduleTool;
#[allow(unused_imports)]
@ -144,6 +146,7 @@ pub fn all_tools_with_runtime(
Box::new(MemoryRecallTool::new(memory.clone())),
Box::new(MemoryForgetTool::new(memory, security.clone())),
Box::new(ScheduleTool::new(security.clone(), root_config.clone())),
Box::new(ProxyConfigTool::new(config.clone(), security.clone())),
Box::new(GitOperationsTool::new(
security.clone(),
workspace_dir.to_path_buf(),
@ -292,6 +295,7 @@ mod tests {
assert!(!names.contains(&"browser_open"));
assert!(names.contains(&"schedule"));
assert!(names.contains(&"pushover"));
assert!(names.contains(&"proxy_config"));
}
#[test]
@ -330,6 +334,7 @@ mod tests {
let names: Vec<&str> = tools.iter().map(|t| t.name()).collect();
assert!(names.contains(&"browser_open"));
assert!(names.contains(&"pushover"));
assert!(names.contains(&"proxy_config"));
}
#[test]

492
src/tools/proxy_config.rs Normal file
View file

@ -0,0 +1,492 @@
use super::traits::{Tool, ToolResult};
use crate::config::{
runtime_proxy_config, set_runtime_proxy_config, Config, ProxyConfig, ProxyScope,
};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::fs;
use std::sync::Arc;
pub struct ProxyConfigTool {
config: Arc<Config>,
security: Arc<SecurityPolicy>,
}
impl ProxyConfigTool {
pub fn new(config: Arc<Config>, security: Arc<SecurityPolicy>) -> Self {
Self { config, security }
}
fn load_config_without_env(&self) -> anyhow::Result<Config> {
let contents = fs::read_to_string(&self.config.config_path).map_err(|error| {
anyhow::anyhow!(
"Failed to read config file {}: {error}",
self.config.config_path.display()
)
})?;
let mut parsed: Config = toml::from_str(&contents).map_err(|error| {
anyhow::anyhow!(
"Failed to parse config file {}: {error}",
self.config.config_path.display()
)
})?;
parsed.config_path = self.config.config_path.clone();
parsed.workspace_dir = self.config.workspace_dir.clone();
Ok(parsed)
}
fn require_write_access(&self) -> Option<ToolResult> {
if !self.security.can_act() {
return Some(ToolResult {
success: false,
output: String::new(),
error: Some("Action blocked: autonomy is read-only".into()),
});
}
if !self.security.record_action() {
return Some(ToolResult {
success: false,
output: String::new(),
error: Some("Action blocked: rate limit exceeded".into()),
});
}
None
}
fn parse_scope(raw: &str) -> Option<ProxyScope> {
match raw.trim().to_ascii_lowercase().as_str() {
"environment" | "env" => Some(ProxyScope::Environment),
"zeroclaw" | "internal" | "core" => Some(ProxyScope::Zeroclaw),
"services" | "service" => Some(ProxyScope::Services),
_ => None,
}
}
fn parse_string_list(raw: &Value, field: &str) -> anyhow::Result<Vec<String>> {
if let Some(raw_string) = raw.as_str() {
return Ok(raw_string
.split(',')
.map(str::trim)
.filter(|entry| !entry.is_empty())
.map(ToOwned::to_owned)
.collect());
}
if let Some(array) = raw.as_array() {
let mut out = Vec::new();
for item in array {
let value = item
.as_str()
.ok_or_else(|| anyhow::anyhow!("'{field}' array must only contain strings"))?;
let trimmed = value.trim();
if !trimmed.is_empty() {
out.push(trimmed.to_string());
}
}
return Ok(out);
}
anyhow::bail!("'{field}' must be a string or string[]")
}
fn parse_optional_string_update(
args: &Value,
field: &str,
) -> anyhow::Result<Option<Option<String>>> {
let Some(raw) = args.get(field) else {
return Ok(None);
};
if raw.is_null() {
return Ok(Some(None));
}
let value = raw
.as_str()
.ok_or_else(|| anyhow::anyhow!("'{field}' must be a string or null"))?
.trim()
.to_string();
Ok(Some((!value.is_empty()).then_some(value)))
}
fn env_snapshot() -> Value {
json!({
"HTTP_PROXY": std::env::var("HTTP_PROXY").ok(),
"HTTPS_PROXY": std::env::var("HTTPS_PROXY").ok(),
"ALL_PROXY": std::env::var("ALL_PROXY").ok(),
"NO_PROXY": std::env::var("NO_PROXY").ok(),
})
}
fn proxy_json(proxy: &ProxyConfig) -> Value {
json!({
"enabled": proxy.enabled,
"scope": proxy.scope,
"http_proxy": proxy.http_proxy,
"https_proxy": proxy.https_proxy,
"all_proxy": proxy.all_proxy,
"no_proxy": proxy.normalized_no_proxy(),
"services": proxy.normalized_services(),
})
}
fn handle_get(&self) -> anyhow::Result<ToolResult> {
let file_proxy = self.load_config_without_env()?.proxy;
let runtime_proxy = runtime_proxy_config();
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&json!({
"proxy": Self::proxy_json(&file_proxy),
"runtime_proxy": Self::proxy_json(&runtime_proxy),
"environment": Self::env_snapshot(),
}))?,
error: None,
})
}
fn handle_list_services(&self) -> anyhow::Result<ToolResult> {
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&json!({
"supported_service_keys": ProxyConfig::supported_service_keys(),
"supported_selectors": ProxyConfig::supported_service_selectors(),
"usage_example": {
"action": "set",
"scope": "services",
"services": ["provider.openai", "tool.http_request", "channel.telegram"]
}
}))?,
error: None,
})
}
fn handle_set(&self, args: &Value) -> anyhow::Result<ToolResult> {
let mut cfg = self.load_config_without_env()?;
let previous_scope = cfg.proxy.scope;
let mut proxy = cfg.proxy.clone();
let mut touched_proxy_url = false;
if let Some(enabled) = args.get("enabled") {
proxy.enabled = enabled
.as_bool()
.ok_or_else(|| anyhow::anyhow!("'enabled' must be a boolean"))?;
}
if let Some(scope_raw) = args.get("scope") {
let scope = scope_raw
.as_str()
.ok_or_else(|| anyhow::anyhow!("'scope' must be a string"))?;
proxy.scope = Self::parse_scope(scope).ok_or_else(|| {
anyhow::anyhow!("Invalid scope '{scope}'. Use environment|zeroclaw|services")
})?;
}
if let Some(update) = Self::parse_optional_string_update(args, "http_proxy")? {
proxy.http_proxy = update;
touched_proxy_url = true;
}
if let Some(update) = Self::parse_optional_string_update(args, "https_proxy")? {
proxy.https_proxy = update;
touched_proxy_url = true;
}
if let Some(update) = Self::parse_optional_string_update(args, "all_proxy")? {
proxy.all_proxy = update;
touched_proxy_url = true;
}
if let Some(no_proxy_raw) = args.get("no_proxy") {
proxy.no_proxy = Self::parse_string_list(no_proxy_raw, "no_proxy")?;
}
if let Some(services_raw) = args.get("services") {
proxy.services = Self::parse_string_list(services_raw, "services")?;
}
if args.get("enabled").is_none() && touched_proxy_url {
proxy.enabled = true;
}
proxy.no_proxy = proxy.normalized_no_proxy();
proxy.services = proxy.normalized_services();
proxy.validate()?;
cfg.proxy = proxy.clone();
cfg.save()?;
set_runtime_proxy_config(proxy.clone());
if proxy.enabled && proxy.scope == ProxyScope::Environment {
proxy.apply_to_process_env();
} else if previous_scope == ProxyScope::Environment {
ProxyConfig::clear_process_env();
}
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&json!({
"message": "Proxy configuration updated",
"proxy": Self::proxy_json(&proxy),
"environment": Self::env_snapshot(),
}))?,
error: None,
})
}
fn handle_disable(&self, args: &Value) -> anyhow::Result<ToolResult> {
let mut cfg = self.load_config_without_env()?;
let clear_env_default = cfg.proxy.scope == ProxyScope::Environment;
cfg.proxy.enabled = false;
cfg.save()?;
set_runtime_proxy_config(cfg.proxy.clone());
let clear_env = args
.get("clear_env")
.and_then(Value::as_bool)
.unwrap_or(clear_env_default);
if clear_env {
ProxyConfig::clear_process_env();
}
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&json!({
"message": "Proxy disabled",
"proxy": Self::proxy_json(&cfg.proxy),
"environment": Self::env_snapshot(),
}))?,
error: None,
})
}
fn handle_apply_env(&self) -> anyhow::Result<ToolResult> {
let cfg = self.load_config_without_env()?;
let proxy = cfg.proxy;
proxy.validate()?;
if !proxy.enabled {
anyhow::bail!("Proxy is disabled. Use action 'set' with enabled=true first");
}
if proxy.scope != ProxyScope::Environment {
anyhow::bail!(
"apply_env only works when proxy.scope is 'environment' (current: {:?})",
proxy.scope
);
}
proxy.apply_to_process_env();
set_runtime_proxy_config(proxy.clone());
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&json!({
"message": "Proxy environment variables applied",
"proxy": Self::proxy_json(&proxy),
"environment": Self::env_snapshot(),
}))?,
error: None,
})
}
fn handle_clear_env(&self) -> anyhow::Result<ToolResult> {
ProxyConfig::clear_process_env();
Ok(ToolResult {
success: true,
output: serde_json::to_string_pretty(&json!({
"message": "Proxy environment variables cleared",
"environment": Self::env_snapshot(),
}))?,
error: None,
})
}
}
#[async_trait]
impl Tool for ProxyConfigTool {
fn name(&self) -> &str {
"proxy_config"
}
fn description(&self) -> &str {
"Manage ZeroClaw proxy settings (scope: environment | zeroclaw | services), including runtime and process env application"
}
fn parameters_schema(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["get", "set", "disable", "list_services", "apply_env", "clear_env"],
"default": "get"
},
"enabled": {
"type": "boolean",
"description": "Enable or disable proxy"
},
"scope": {
"type": "string",
"description": "Proxy scope: environment | zeroclaw | services"
},
"http_proxy": {
"type": ["string", "null"],
"description": "HTTP proxy URL"
},
"https_proxy": {
"type": ["string", "null"],
"description": "HTTPS proxy URL"
},
"all_proxy": {
"type": ["string", "null"],
"description": "Fallback proxy URL for all protocols"
},
"no_proxy": {
"description": "Comma-separated string or array of NO_PROXY entries",
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
]
},
"services": {
"description": "Comma-separated string or array of service selectors used when scope=services",
"oneOf": [
{"type": "string"},
{"type": "array", "items": {"type": "string"}}
]
},
"clear_env": {
"type": "boolean",
"description": "When action=disable, clear process proxy environment variables"
}
}
})
}
async fn execute(&self, args: Value) -> anyhow::Result<ToolResult> {
let action = args
.get("action")
.and_then(Value::as_str)
.unwrap_or("get")
.to_ascii_lowercase();
let result = match action.as_str() {
"get" => self.handle_get(),
"list_services" => self.handle_list_services(),
"set" | "disable" | "apply_env" | "clear_env" => {
if let Some(blocked) = self.require_write_access() {
return Ok(blocked);
}
match action.as_str() {
"set" => self.handle_set(&args),
"disable" => self.handle_disable(&args),
"apply_env" => self.handle_apply_env(),
"clear_env" => self.handle_clear_env(),
_ => unreachable!("handled above"),
}
}
_ => anyhow::bail!(
"Unknown action '{action}'. Valid: get, set, disable, list_services, apply_env, clear_env"
),
};
match result {
Ok(outcome) => Ok(outcome),
Err(error) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(error.to_string()),
}),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::security::{AutonomyLevel, SecurityPolicy};
use tempfile::TempDir;
fn test_security() -> Arc<SecurityPolicy> {
Arc::new(SecurityPolicy {
autonomy: AutonomyLevel::Supervised,
workspace_dir: std::env::temp_dir(),
..SecurityPolicy::default()
})
}
fn test_config(tmp: &TempDir) -> Arc<Config> {
let config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
};
config.save().unwrap();
Arc::new(config)
}
#[tokio::test]
async fn list_services_action_returns_known_keys() {
let tmp = TempDir::new().unwrap();
let tool = ProxyConfigTool::new(test_config(&tmp), test_security());
let result = tool
.execute(json!({"action": "list_services"}))
.await
.unwrap();
assert!(result.success);
assert!(result.output.contains("provider.openai"));
assert!(result.output.contains("tool.http_request"));
}
#[tokio::test]
async fn set_scope_services_requires_services_entries() {
let tmp = TempDir::new().unwrap();
let tool = ProxyConfigTool::new(test_config(&tmp), test_security());
let result = tool
.execute(json!({
"action": "set",
"enabled": true,
"scope": "services",
"http_proxy": "http://127.0.0.1:7890",
"services": []
}))
.await
.unwrap();
assert!(!result.success);
assert!(result
.error
.unwrap_or_default()
.contains("proxy.scope='services'"));
}
#[tokio::test]
async fn set_and_get_round_trip_proxy_scope() {
let tmp = TempDir::new().unwrap();
let tool = ProxyConfigTool::new(test_config(&tmp), test_security());
let set_result = tool
.execute(json!({
"action": "set",
"scope": "services",
"http_proxy": "http://127.0.0.1:7890",
"services": ["provider.openai", "tool.http_request"]
}))
.await
.unwrap();
assert!(set_result.success, "{:?}", set_result.error);
let get_result = tool.execute(json!({"action": "get"})).await.unwrap();
assert!(get_result.success);
assert!(get_result.output.contains("provider.openai"));
assert!(get_result.output.contains("services"));
}
}

View file

@ -1,30 +1,21 @@
use super::traits::{Tool, ToolResult};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use reqwest::Client;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
const PUSHOVER_API_URL: &str = "https://api.pushover.net/1/messages.json";
const PUSHOVER_REQUEST_TIMEOUT_SECS: u64 = 15;
pub struct PushoverTool {
client: Client,
security: Arc<SecurityPolicy>,
workspace_dir: PathBuf,
}
impl PushoverTool {
pub fn new(security: Arc<SecurityPolicy>, workspace_dir: PathBuf) -> Self {
let client = Client::builder()
.timeout(Duration::from_secs(PUSHOVER_REQUEST_TIMEOUT_SECS))
.build()
.unwrap_or_else(|_| Client::new());
Self {
client,
security,
workspace_dir,
}
@ -182,12 +173,12 @@ impl Tool for PushoverTool {
form = form.text("sound", sound);
}
let response = self
.client
.post(PUSHOVER_API_URL)
.multipart(form)
.send()
.await?;
let client = crate::config::build_runtime_proxy_client_with_timeouts(
"tool.pushover",
PUSHOVER_REQUEST_TIMEOUT_SECS,
10,
);
let response = client.post(PUSHOVER_API_URL).multipart(form).send().await?;
let status = response.status();
let body = response.text().await.unwrap_or_default();

View file

@ -123,7 +123,7 @@ impl Tunnel for CustomTunnel {
async fn health_check(&self) -> bool {
// If a health URL is configured, try to reach it
if let Some(ref url) = self.health_url {
return reqwest::Client::new()
return crate::config::build_runtime_proxy_client("tunnel.custom")
.get(url)
.timeout(std::time::Duration::from_secs(5))
.send()