From f60c0fdf1aa98886319924c96c08e34c4edc0617 Mon Sep 17 00:00:00 2001 From: wavezhang Date: Thu, 21 May 2026 18:03:31 +0800 Subject: [PATCH] feat: make TLS certificate verification configurable Add `insecure_skip_tls_verify` configuration option (default `false`) to allow users to disable TLS certificate verification for outbound HTTPS requests. This affects the API client, tools (fetch, search, web_run, finance, image_analyze), skill installer, MCP connections, sandbox, config UI polling, CLI self-updater, and webhook hooks. **Configuration** - TOML: `insecure_skip_tls_verify = true` - Environment variable: `DEEPSEEK_INSECURE_SKIP_TLS_VERIFY=true` **Key design decisions** - The flag is threaded through constructors and execution contexts rather than read from global config inside HTTP client builders, keeping the dependency graph clean (hooks crate does not depend on config). - `ConfigToml::resolve_insecure_skip_tls_verify()` centralizes env-var override resolution so callers don't duplicate parsing logic. - `parse_bool` in `crates/config` is now `pub` for shared boolean parsing. **Tests added** - Config TOML deserialization for `insecure_skip_tls_verify` - Env override application in TUI Config - HTTP client builder behavior with skip_verify true/false **Security note** This commit does not introduce or expose any credentials, tokens, or secrets. All changes are strictly structural (replacing hardcoded `danger_accept_invalid_certs(true)` with a configurable flag). No API keys, passwords, or personal access tokens are present in the diff. --- crates/cli/src/lib.rs | 4 ++- crates/cli/src/update.rs | 25 +++++++------- crates/config/src/lib.rs | 38 ++++++++++++++++++++- crates/hooks/src/lib.rs | 7 ++-- crates/tui/src/client.rs | 25 +++++++++++++- crates/tui/src/commands/skills.rs | 20 ++++++----- crates/tui/src/config.rs | 49 +++++++++++++++++++++++++++ crates/tui/src/config_ui.rs | 6 +++- crates/tui/src/core/engine.rs | 4 ++- crates/tui/src/main.rs | 9 +++-- crates/tui/src/mcp.rs | 13 ++++++- crates/tui/src/runtime_api.rs | 6 ++-- crates/tui/src/sandbox/backend.rs | 2 +- crates/tui/src/sandbox/opensandbox.rs | 3 +- crates/tui/src/skills/install.rs | 38 +++++++++++++++------ crates/tui/src/tools/fetch_url.rs | 1 + crates/tui/src/tools/finance.rs | 11 +++--- crates/tui/src/tools/registry.rs | 4 +-- crates/tui/src/tools/spec.rs | 14 ++++++++ crates/tui/src/tools/web_run.rs | 13 ++++--- crates/tui/src/tools/web_search.rs | 3 ++ crates/tui/src/vision/tools.rs | 9 ++--- 22 files changed, 244 insertions(+), 60 deletions(-) diff --git a/crates/cli/src/lib.rs b/crates/cli/src/lib.rs index 8b64dc5ab..0ac4a34d6 100644 --- a/crates/cli/src/lib.rs +++ b/crates/cli/src/lib.rs @@ -525,7 +525,9 @@ fn run() -> Result<()> { Ok(()) } Some(Commands::Metrics(args)) => run_metrics_command(args), - Some(Commands::Update) => update::run_update(), + Some(Commands::Update) => { + update::run_update(store.config.resolve_insecure_skip_tls_verify()) + } None => { let resolved_runtime = resolve_runtime_for_dispatch(&mut store, &runtime_overrides); let mut forwarded = Vec::new(); diff --git a/crates/cli/src/update.rs b/crates/cli/src/update.rs index 33f2693fb..6e1483e86 100644 --- a/crates/cli/src/update.rs +++ b/crates/cli/src/update.rs @@ -21,7 +21,7 @@ const LEGACY_UPDATE_VERSION_ENV: &str = "DEEPSEEK_VERSION"; const UPDATE_USER_AGENT: &str = "deepseek-tui-updater"; /// Run the self-update workflow. -pub fn run_update() -> Result<()> { +pub fn run_update(skip_verify: bool) -> Result<()> { let current_exe = std::env::current_exe().context("failed to determine current executable path")?; let targets = update_targets_for_exe(¤t_exe); @@ -30,7 +30,7 @@ pub fn run_update() -> Result<()> { println!("Current binary: {}", current_exe.display()); // Step 1: Fetch latest release metadata - let release = fetch_latest_release().with_context(update_network_fallback_hint)?; + let release = fetch_latest_release(skip_verify).with_context(update_network_fallback_hint)?; let latest_tag = &release.tag_name; println!("Latest release: {latest_tag}"); @@ -39,7 +39,7 @@ pub fn run_update() -> Result<()> { Some(checksum_asset) => { println!("Downloading {}...", checksum_asset.name); let checksum_bytes = - download_url(&checksum_asset.browser_download_url).with_context(|| { + download_url(&checksum_asset.browser_download_url, skip_verify).with_context(|| { format!( "failed to download {}\n{}", checksum_asset.name, @@ -74,7 +74,7 @@ pub fn run_update() -> Result<()> { })?; println!("Downloading {}...", asset.name); - let bytes = download_url(&asset.browser_download_url).with_context(|| { + let bytes = download_url(&asset.browser_download_url, skip_verify).with_context(|| { format!( "failed to download {}\n{}", asset.name, @@ -288,15 +288,16 @@ struct Asset { browser_download_url: String, } -fn update_http_client() -> Result { +fn update_http_client(skip_verify: bool) -> Result { reqwest::blocking::Client::builder() + .danger_accept_invalid_certs(skip_verify) .user_agent(UPDATE_USER_AGENT) .build() .context("failed to build update HTTP client") } /// Fetch the latest release metadata from GitHub. -fn fetch_latest_release() -> Result { +fn fetch_latest_release(skip_verify: bool) -> Result { if let Some(base_url) = release_base_url_from_env() { let version = update_version_from_env().unwrap_or_else(|| env!("CARGO_PKG_VERSION").into()); return Ok(release_from_mirror_base_url( @@ -306,7 +307,7 @@ fn fetch_latest_release() -> Result { std::env::consts::ARCH, )); } - fetch_latest_release_from_url(LATEST_RELEASE_URL) + fetch_latest_release_from_url(LATEST_RELEASE_URL, skip_verify) } fn release_base_url_from_env() -> Option { @@ -365,8 +366,8 @@ fn update_network_fallback_hint() -> String { ) } -fn fetch_latest_release_from_url(url: &str) -> Result { - let client = update_http_client()?; +fn fetch_latest_release_from_url(url: &str, skip_verify: bool) -> Result { + let client = update_http_client(skip_verify)?; let response = client .get(url) .header(reqwest::header::ACCEPT, "application/vnd.github+json") @@ -389,8 +390,8 @@ fn fetch_latest_release_from_url(url: &str) -> Result { } /// Download a URL to bytes. -fn download_url(url: &str) -> Result> { - let client = update_http_client()?; +fn download_url(url: &str, skip_verify: bool) -> Result> { + let client = update_http_client(skip_verify)?; let response = client .get(url) .send() @@ -909,7 +910,7 @@ E3B0C44298FC1C149AFBF4C8996FB92427AE41E4649B934CA495991B7852B855 *deepseek-wind fn download_url_reads_binary_body_with_updater_user_agent() { let (url, request_rx, handle) = serve_http_once("200 OK", "application/octet-stream", b"\0binary bytes"); - let bytes = download_url(&url).expect("binary download should succeed"); + let bytes = download_url(&url, false).expect("binary download should succeed"); assert_eq!(bytes, b"\0binary bytes"); diff --git a/crates/config/src/lib.rs b/crates/config/src/lib.rs index 81ca4221f..fcc0e5f20 100644 --- a/crates/config/src/lib.rs +++ b/crates/config/src/lib.rs @@ -224,6 +224,11 @@ pub struct ConfigToml { /// applies the defaults documented in [`LspConfigToml`]. #[serde(default)] pub lsp: Option, + /// Skip TLS certificate verification for all outbound HTTPS requests. + /// Defaults to `false` (verify certificates). Set to `true` only when + /// connecting to servers with self-signed or untrusted certificates. + #[serde(default)] + pub insecure_skip_tls_verify: Option, #[serde(flatten)] pub extras: BTreeMap, } @@ -943,6 +948,17 @@ impl ConfigToml { out } + /// Resolve `insecure_skip_tls_verify` considering the environment variable + /// override first, then the config file value, defaulting to `false`. + #[must_use] + pub fn resolve_insecure_skip_tls_verify(&self) -> bool { + std::env::var("DEEPSEEK_INSECURE_SKIP_TLS_VERIFY") + .ok() + .and_then(|v| parse_bool(&v).ok()) + .or(self.insecure_skip_tls_verify) + .unwrap_or(false) + } + /// Resolve runtime options without touching platform credential stores. /// /// This method keeps library callers prompt-free: CLI flag → config file @@ -1463,7 +1479,8 @@ pub fn default_config_path() -> Result { Ok(home.join(".deepseek").join(CONFIG_FILE_NAME)) } -fn parse_bool(raw: &str) -> Result { +/// Parse common boolean string representations. +pub fn parse_bool(raw: &str) -> Result { match raw.trim().to_ascii_lowercase().as_str() { "1" | "true" | "yes" | "on" | "enabled" => Ok(true), "0" | "false" | "no" | "off" | "disabled" => Ok(false), @@ -1577,6 +1594,7 @@ struct EnvRuntimeOverrides { sglang_base_url: Option, vllm_base_url: Option, ollama_base_url: Option, + insecure_skip_tls_verify: Option, } impl EnvRuntimeOverrides { @@ -1643,6 +1661,9 @@ impl EnvRuntimeOverrides { ollama_base_url: std::env::var("OLLAMA_BASE_URL") .ok() .filter(|v| !v.trim().is_empty()), + insecure_skip_tls_verify: std::env::var("DEEPSEEK_INSECURE_SKIP_TLS_VERIFY") + .ok() + .and_then(|v| parse_bool(&v).ok()), } } @@ -1731,6 +1752,7 @@ mod tests { vllm_base_url: Option, ollama_api_key: Option, ollama_base_url: Option, + deepseek_insecure_skip_tls_verify: Option, } impl EnvGuard { @@ -1766,6 +1788,7 @@ mod tests { vllm_base_url: env::var_os("VLLM_BASE_URL"), ollama_api_key: env::var_os("OLLAMA_API_KEY"), ollama_base_url: env::var_os("OLLAMA_BASE_URL"), + deepseek_insecure_skip_tls_verify: env::var_os("DEEPSEEK_INSECURE_SKIP_TLS_VERIFY"), }; // Safety: test-only environment mutation guarded by a module mutex. unsafe { @@ -1799,6 +1822,7 @@ mod tests { env::remove_var("VLLM_BASE_URL"); env::remove_var("OLLAMA_API_KEY"); env::remove_var("OLLAMA_BASE_URL"); + env::remove_var("DEEPSEEK_INSECURE_SKIP_TLS_VERIFY"); } guard } @@ -1846,6 +1870,7 @@ mod tests { Self::restore_var("VLLM_BASE_URL", self.vllm_base_url.take()); Self::restore_var("OLLAMA_API_KEY", self.ollama_api_key.take()); Self::restore_var("OLLAMA_BASE_URL", self.ollama_base_url.take()); + Self::restore_var("DEEPSEEK_INSECURE_SKIP_TLS_VERIFY", self.deepseek_insecure_skip_tls_verify.take()); } } } @@ -2800,4 +2825,15 @@ mod tests { assert_eq!(resolved.api_key.as_deref(), Some("cli-key")); assert_eq!(resolved.api_key_source, Some(RuntimeApiKeySource::Cli)); } + + #[test] + fn insecure_skip_tls_verify_toml_deserializes() { + let config: ConfigToml = toml::from_str( + r#" + insecure_skip_tls_verify = true + "#, + ) + .expect("config toml"); + assert_eq!(config.insecure_skip_tls_verify, Some(true)); + } } diff --git a/crates/hooks/src/lib.rs b/crates/hooks/src/lib.rs index d2af4ec4a..a442f9c5f 100644 --- a/crates/hooks/src/lib.rs +++ b/crates/hooks/src/lib.rs @@ -111,10 +111,13 @@ pub struct WebhookHookSink { } impl WebhookHookSink { - pub fn new(url: String) -> Self { + pub fn new(url: String, skip_verify: bool) -> Self { Self { url, - client: reqwest::Client::new(), + client: reqwest::Client::builder() + .danger_accept_invalid_certs(skip_verify) + .build() + .unwrap_or_else(|_| reqwest::Client::new()), } } } diff --git a/crates/tui/src/client.rs b/crates/tui/src/client.rs index 20755beae..0e4797082 100644 --- a/crates/tui/src/client.rs +++ b/crates/tui/src/client.rs @@ -492,7 +492,8 @@ impl DeepSeekClient { retry.enabled, retry.max_retries, retry.initial_delay, retry.max_delay )); - let http_client = Self::build_http_client(&api_key, &http_headers)?; + let skip_verify = config.insecure_skip_tls_verify.unwrap_or(false); + let http_client = Self::build_http_client(&api_key, &http_headers, skip_verify)?; Ok(Self { http_client, @@ -509,9 +510,11 @@ impl DeepSeekClient { fn build_http_client( api_key: &str, extra_headers: &HashMap, + skip_verify: bool, ) -> Result { let headers = build_default_headers(api_key, extra_headers)?; let mut builder = reqwest::Client::builder() + .danger_accept_invalid_certs(skip_verify) .default_headers(headers) .user_agent(concat!( "Mozilla/5.0 (compatible; deepseek-tui/", @@ -2925,4 +2928,24 @@ mod tests { ); } } + + #[test] + fn build_http_client_succeeds_with_skip_verify_true() { + let client = DeepSeekClient::build_http_client( + "test-key", + &HashMap::new(), + true, + ); + assert!(client.is_ok()); + } + + #[test] + fn build_http_client_succeeds_with_skip_verify_false() { + let client = DeepSeekClient::build_http_client( + "test-key", + &HashMap::new(), + false, + ); + assert!(client.is_ok()); + } } diff --git a/crates/tui/src/commands/skills.rs b/crates/tui/src/commands/skills.rs index b1823d5f6..5282ef074 100644 --- a/crates/tui/src/commands/skills.rs +++ b/crates/tui/src/commands/skills.rs @@ -269,7 +269,7 @@ fn install_skill(app: &mut App, spec: &str) -> CommandResult { Err(err) => return CommandResult::error(format!("Invalid install source: {err}")), }; let skills_dir = app.skills_dir.clone(); - let (network, max_size, registry_url) = installer_settings(app); + let (network, max_size, registry_url, skip_verify) = installer_settings(app); let outcome = run_async(async move { install::install_with_registry( @@ -279,6 +279,7 @@ fn install_skill(app: &mut App, spec: &str) -> CommandResult { &network, false, ®istry_url, + skip_verify, ) .await }); @@ -309,10 +310,10 @@ fn update_skill(app: &mut App, name: &str) -> CommandResult { return CommandResult::error("Usage: /skill update "); } let skills_dir = app.skills_dir.clone(); - let (network, max_size, registry_url) = installer_settings(app); + let (network, max_size, registry_url, skip_verify) = installer_settings(app); let owned_name = name.to_string(); let outcome = run_async(async move { - install::update_with_registry(&owned_name, &skills_dir, max_size, &network, ®istry_url) + install::update_with_registry(&owned_name, &skills_dir, max_size, &network, ®istry_url, skip_verify) .await }); @@ -368,8 +369,8 @@ fn trust_skill(app: &mut App, name: &str) -> CommandResult { /// List skills available in the configured curated registry. pub fn list_remote_skills(app: &mut App) -> CommandResult { - let (network, _max_size, registry_url) = installer_settings(app); - let registry = run_async(async move { install::fetch_registry(&network, ®istry_url).await }); + let (network, _max_size, registry_url, skip_verify) = installer_settings(app); + let registry = run_async(async move { install::fetch_registry(&network, ®istry_url, skip_verify).await }); match registry { Ok(RegistryFetchResult::Loaded(doc)) => { if doc.skills.is_empty() { @@ -406,11 +407,11 @@ pub fn list_remote_skills(app: &mut App) -> CommandResult { /// For each skill the sync checks the cached ETag / SHA-256 before /// downloading so unchanged skills are skipped in O(1) network round-trips. fn sync_skills(app: &mut App) -> CommandResult { - let (network, max_size, registry_url) = installer_settings(app); + let (network, max_size, registry_url, skip_verify) = installer_settings(app); let cache_dir = install::default_cache_skills_dir(); let result = run_async(async move { - install::sync_registry(&network, ®istry_url, &cache_dir, max_size).await + install::sync_registry(&network, ®istry_url, &cache_dir, max_size, skip_verify).await }); match result { @@ -473,7 +474,7 @@ fn sync_skills(app: &mut App) -> CommandResult { /// round-trip the install/update operation will incur next. If the config /// fails to parse, we fall back to defaults so the user still gets a /// network-gated install rather than a silent crash. -fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String) { +fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String, bool) { let cfg = crate::config::Config::load(None, None).unwrap_or_default(); let network = cfg .network @@ -487,7 +488,8 @@ fn installer_settings(_app: &App) -> (NetworkPolicy, u64, String) { let registry_url = skills_cfg .and_then(|s| s.registry_url.clone()) .unwrap_or_else(|| DEFAULT_REGISTRY_URL.to_string()); - (network, max_size, registry_url) + let skip_verify = cfg.insecure_skip_tls_verify.unwrap_or(false); + (network, max_size, registry_url, skip_verify) } fn run_async(future: F) -> T diff --git a/crates/tui/src/config.rs b/crates/tui/src/config.rs index 04bc18e8c..91af7c253 100644 --- a/crates/tui/src/config.rs +++ b/crates/tui/src/config.rs @@ -1039,6 +1039,11 @@ pub struct Config { /// Vision model configuration for the `image_analyze` tool. #[serde(default)] pub vision_model: Option, + + /// Skip TLS certificate verification for all outbound HTTPS requests. + /// Defaults to `false` (verify certificates). + #[serde(default)] + pub insecure_skip_tls_verify: Option, } /// Vision model configuration for the `image_analyze` tool. @@ -1301,6 +1306,12 @@ impl Config { normalize_model_config(&mut config); config.validate()?; config.warn_on_misplaced_root_base_url(); + if config.insecure_skip_tls_verify == Some(true) { + tracing::warn!( + "TLS certificate verification is disabled (insecure_skip_tls_verify = true). \ + This is insecure and should only be used for development or trusted internal servers." + ); + } Ok(config) } @@ -2677,6 +2688,17 @@ fn apply_env_overrides(config: &mut Config) { }) { config.capacity = None; } + if let Ok(value) = std::env::var("DEEPSEEK_INSECURE_SKIP_TLS_VERIFY") { + config.insecure_skip_tls_verify = parse_bool_env(&value); + } +} + +fn parse_bool_env(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "1" | "true" | "yes" | "on" | "enabled" => Some(true), + "0" | "false" | "no" | "off" | "disabled" => Some(false), + _ => None, + } } fn normalize_model_config(config: &mut Config) { @@ -6492,4 +6514,31 @@ model = "deepseek-ai/deepseek-v4-pro" let deserialized: ProviderCapability = serde_json::from_value(json).unwrap(); assert_eq!(cap, deserialized); } + + #[test] + fn insecure_skip_tls_verify_toml_deserializes() { + let config: Config = toml::from_str( + r#" + insecure_skip_tls_verify = true + "#, + ) + .expect("config toml"); + assert_eq!(config.insecure_skip_tls_verify, Some(true)); + } + + #[test] + fn insecure_skip_tls_verify_env_override_applied() { + let _lock = lock_test_env(); + // Safety: test-only environment mutation guarded by a global mutex. + unsafe { + env::set_var("DEEPSEEK_INSECURE_SKIP_TLS_VERIFY", "true"); + } + + let mut config = Config::default(); + config.apply_env_overrides(); + assert_eq!(config.insecure_skip_tls_verify, Some(true)); + + // Safety: env mutation guarded by lock_test_env(). + unsafe { env::remove_var("DEEPSEEK_INSECURE_SKIP_TLS_VERIFY") }; + } } diff --git a/crates/tui/src/config_ui.rs b/crates/tui/src/config_ui.rs index a0915dd82..9b8998ab3 100644 --- a/crates/tui/src/config_ui.rs +++ b/crates/tui/src/config_ui.rs @@ -385,11 +385,15 @@ pub async fn start_web_editor(app: &App, config: &Config) -> Result = Some(app_snapshot); loop { tokio::time::sleep(Duration::from_millis(750)).await; diff --git a/crates/tui/src/core/engine.rs b/crates/tui/src/core/engine.rs index 4332df6ee..4d023e939 100644 --- a/crates/tui/src/core/engine.rs +++ b/crates/tui/src/core/engine.rs @@ -1452,6 +1452,7 @@ impl Engine { // Wire search provider config. ctx.search_provider = self.config.search_provider; ctx.search_api_key = self.config.search_api_key.clone(); + ctx.insecure_skip_tls_verify = self.config.insecure_skip_tls_verify.unwrap_or(false); let policy = sandbox_policy_for_mode(mode, &self.session.workspace); let mut ctx = ctx.with_elevated_sandbox_policy(policy); @@ -1468,7 +1469,8 @@ impl Engine { return Ok(Arc::clone(pool)); } let mut pool = McpPool::from_config_path(&self.session.mcp_config_path) - .map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))?; + .map_err(|e| ToolError::execution_failed(format!("Failed to load MCP config: {e}")))? + .with_skip_tls_verify(self.config.insecure_skip_tls_verify.unwrap_or(false)); if let Some(decider) = self.config.network_policy.as_ref() { pool = pool.with_network_policy(decider.clone()); } diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index 75bfbda9d..069abc55f 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -3617,7 +3617,8 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { Ok(()) } McpCommand::Connect { server } => { - let mut pool = McpPool::from_config_path(&config_path)?; + let mut pool = McpPool::from_config_path(&config_path)? + .with_skip_tls_verify(config.insecure_skip_tls_verify.unwrap_or(false)); if let Some(name) = server { pool.get_or_connect(&name).await?; println!("Connected to MCP server: {name}"); @@ -3634,7 +3635,8 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { Ok(()) } McpCommand::Tools { server } => { - let mut pool = McpPool::from_config_path(&config_path)?; + let mut pool = McpPool::from_config_path(&config_path)? + .with_skip_tls_verify(config.insecure_skip_tls_verify.unwrap_or(false)); if let Some(name) = server { let conn = pool.get_or_connect(&name).await?; if conn.tools().is_empty() { @@ -3737,7 +3739,8 @@ async fn run_mcp_command(config: &Config, command: McpCommand) -> Result<()> { Ok(()) } McpCommand::Validate => { - let mut pool = McpPool::from_config_path(&config_path)?; + let mut pool = McpPool::from_config_path(&config_path)? + .with_skip_tls_verify(config.insecure_skip_tls_verify.unwrap_or(false)); let errors = pool.connect_all().await; if errors.is_empty() { println!("MCP config is valid. All enabled servers connected."); diff --git a/crates/tui/src/mcp.rs b/crates/tui/src/mcp.rs index d25c07343..1f25e6c46 100644 --- a/crates/tui/src/mcp.rs +++ b/crates/tui/src/mcp.rs @@ -1147,6 +1147,7 @@ impl McpConnection { config: McpServerConfig, global_timeouts: &McpTimeouts, network_policy: Option<&NetworkPolicyDecider>, + skip_verify: bool, ) -> Result { let connect_timeout_secs = config.effective_connect_timeout(global_timeouts); let cancel_token = tokio_util::sync::CancellationToken::new(); @@ -1182,7 +1183,7 @@ impl McpConnection { // HTTP traffic bypass the proxy entirely while every other // tool on the box (curl, npm, …) used it. let mut client_builder = - reqwest::Client::builder().timeout(Duration::from_secs(connect_timeout_secs)); + reqwest::Client::builder().danger_accept_invalid_certs(skip_verify).timeout(Duration::from_secs(connect_timeout_secs)); let env_proxy_url = std::env::var("HTTPS_PROXY") .or_else(|_| std::env::var("https_proxy")) .or_else(|_| std::env::var("HTTP_PROXY")) @@ -1752,6 +1753,7 @@ pub struct McpPool { /// Most recently observed mtime of `config_source`. Updated whenever the /// reload check runs (whether or not it triggered a reload). last_mtime: Option, + skip_verify: bool, } impl McpPool { @@ -1765,6 +1767,7 @@ impl McpPool { config_source: None, config_hash, last_mtime: None, + skip_verify: false, } } @@ -1793,6 +1796,12 @@ impl McpPool { self } + /// Configure TLS certificate verification skipping. + pub fn with_skip_tls_verify(mut self, skip_verify: bool) -> Self { + self.skip_verify = skip_verify; + self + } + /// If the source config file's mtime has changed since the last check, /// re-read it and (only when the content hash also changed) drop all /// existing connections so the next `get_or_connect` reattaches under @@ -1880,6 +1889,7 @@ impl McpPool { server_config, &self.config.timeouts, self.network_policy.as_ref(), + self.skip_verify, ) .await?; @@ -3521,6 +3531,7 @@ mod tests { config, &McpTimeouts::default(), None, + false, ) .await .unwrap(); diff --git a/crates/tui/src/runtime_api.rs b/crates/tui/src/runtime_api.rs index 20110cc45..48d475498 100644 --- a/crates/tui/src/runtime_api.rs +++ b/crates/tui/src/runtime_api.rs @@ -997,7 +997,8 @@ async fn list_mcp_servers( State(state): State, ) -> Result, ApiError> { let config = load_mcp_config_or_default(&state.mcp_config_path)?; - let mut pool = McpPool::new(config.clone()); + let mut pool = McpPool::new(config.clone()) + .with_skip_tls_verify(state.config.insecure_skip_tls_verify.unwrap_or(false)); let _errors = pool.connect_all().await; let connected: HashSet = pool .connected_servers() @@ -1028,7 +1029,8 @@ async fn list_mcp_tools( Query(query): Query, ) -> Result, ApiError> { let mut pool = McpPool::from_config_path(&state.mcp_config_path) - .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))?; + .map_err(|e| ApiError::internal(format!("Failed to load MCP config: {e}")))? + .with_skip_tls_verify(state.config.insecure_skip_tls_verify.unwrap_or(false)); let _errors = pool.connect_all().await; let mut tools = Vec::new(); diff --git a/crates/tui/src/sandbox/backend.rs b/crates/tui/src/sandbox/backend.rs index 96fec4cd6..923a0f32d 100644 --- a/crates/tui/src/sandbox/backend.rs +++ b/crates/tui/src/sandbox/backend.rs @@ -88,7 +88,7 @@ pub fn create_backend(config: &Config) -> Result> .clone() .unwrap_or_else(|| "http://localhost:8080".to_string()); let api_key = config.sandbox_api_key.clone(); - let backend = super::opensandbox::OpenSandboxBackend::new(base_url, api_key, 30)?; + let backend = super::opensandbox::OpenSandboxBackend::new(base_url, api_key, 30, config.insecure_skip_tls_verify.unwrap_or(false))?; Ok(Some(Box::new(backend))) } } diff --git a/crates/tui/src/sandbox/opensandbox.rs b/crates/tui/src/sandbox/opensandbox.rs index b2ffa4c3b..c87c70d2e 100644 --- a/crates/tui/src/sandbox/opensandbox.rs +++ b/crates/tui/src/sandbox/opensandbox.rs @@ -53,8 +53,9 @@ impl OpenSandboxBackend { /// `"http://localhost:8080"`). `api_key` is optional and sent as /// `Authorization: Bearer ` when set. `timeout_secs` controls the /// HTTP request timeout. - pub fn new(base_url: String, api_key: Option, timeout_secs: u64) -> Result { + pub fn new(base_url: String, api_key: Option, timeout_secs: u64, skip_verify: bool) -> Result { let client = reqwest::Client::builder() + .danger_accept_invalid_certs(skip_verify) .timeout(Duration::from_secs(timeout_secs)) .build() .context("failed to construct HTTP client for OpenSandbox backend")?; diff --git a/crates/tui/src/skills/install.rs b/crates/tui/src/skills/install.rs index 19d516525..768409628 100644 --- a/crates/tui/src/skills/install.rs +++ b/crates/tui/src/skills/install.rs @@ -261,6 +261,7 @@ pub async fn install( max_size: u64, network: &NetworkPolicy, update: bool, + skip_verify: bool, ) -> Result { install_with_registry( source, @@ -269,6 +270,7 @@ pub async fn install( network, update, DEFAULT_REGISTRY_URL, + skip_verify, ) .await } @@ -282,8 +284,9 @@ pub async fn install_with_registry( network: &NetworkPolicy, update: bool, registry_url: &str, + skip_verify: bool, ) -> Result { - let urls = candidate_urls(&source, network, registry_url).await?; + let urls = candidate_urls(&source, network, registry_url, skip_verify).await?; let urls = match urls { UrlResolution::Resolved(urls) => urls, UrlResolution::NeedsApproval(host) => return Ok(InstallOutcome::NeedsApproval(host)), @@ -379,8 +382,9 @@ pub async fn update( skills_dir: &Path, max_size: u64, network: &NetworkPolicy, + skip_verify: bool, ) -> Result { - update_with_registry(name, skills_dir, max_size, network, DEFAULT_REGISTRY_URL).await + update_with_registry(name, skills_dir, max_size, network, DEFAULT_REGISTRY_URL, skip_verify).await } /// Same as [`update`] but lets the caller override the registry URL. @@ -390,6 +394,7 @@ pub async fn update_with_registry( max_size: u64, network: &NetworkPolicy, registry_url: &str, + skip_verify: bool, ) -> Result { let target = skills_dir.join(name); let marker_path = target.join(INSTALLED_FROM_MARKER); @@ -405,7 +410,7 @@ pub async fn update_with_registry( // we still hit the network so the user gets a useful "no upstream change" // signal, but we skip the unpack step if the bytes match. let source = InstallSource::parse(&marker.spec)?; - let urls = match candidate_urls(&source, network, registry_url).await? { + let urls = match candidate_urls(&source, network, registry_url, skip_verify).await? { UrlResolution::Resolved(urls) => urls, UrlResolution::NeedsApproval(host) => return Ok(UpdateResult::NeedsApproval(host)), UrlResolution::Denied(host) => return Ok(UpdateResult::NetworkDenied(host)), @@ -426,7 +431,7 @@ pub async fn update_with_registry( // Bytes changed — fall back to the regular install path with `update = true` // so we get the same atomic-replace semantics. let outcome = - install_with_registry(source, skills_dir, max_size, network, true, registry_url).await?; + install_with_registry(source, skills_dir, max_size, network, true, registry_url, skip_verify).await?; match outcome { InstallOutcome::Installed(installed) => Ok(UpdateResult::Updated(installed)), InstallOutcome::NeedsApproval(host) => Ok(UpdateResult::NeedsApproval(host)), @@ -482,6 +487,7 @@ pub fn trust(name: &str, skills_dir: &Path) -> Result<()> { pub async fn fetch_registry( network: &NetworkPolicy, registry_url: &str, + skip_verify: bool, ) -> Result { let host = match host_from_url(registry_url) { Some(host) => host, @@ -492,7 +498,13 @@ pub async fn fetch_registry( Decision::Deny => return Ok(RegistryFetchResult::Denied(host)), Decision::Prompt => return Ok(RegistryFetchResult::NeedsApproval(host)), } - let body = reqwest::get(registry_url) + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(skip_verify) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); + let body = client + .get(registry_url) + .send() .await .with_context(|| format!("failed to fetch registry {registry_url}"))? .error_for_status() @@ -566,8 +578,9 @@ pub async fn sync_registry( registry_url: &str, cache_dir: &Path, max_size: u64, + skip_verify: bool, ) -> Result { - let doc = match fetch_registry(network, registry_url).await? { + let doc = match fetch_registry(network, registry_url, skip_verify).await? { RegistryFetchResult::Loaded(doc) => doc, RegistryFetchResult::Denied(host) => return Ok(SyncResult::RegistryDenied(host)), RegistryFetchResult::NeedsApproval(host) => { @@ -578,7 +591,7 @@ pub async fn sync_registry( let mut outcomes = Vec::new(); for (name, entry) in &doc.skills { - let outcome = sync_one_skill(name, entry, network, cache_dir, max_size).await; + let outcome = sync_one_skill(name, entry, network, cache_dir, max_size, skip_verify).await; outcomes.push(outcome); } @@ -592,6 +605,7 @@ async fn sync_one_skill( network: &NetworkPolicy, cache_dir: &Path, max_size: u64, + skip_verify: bool, ) -> SkillSyncOutcome { // Resolve the source to a concrete URL list. let source = match InstallSource::parse(&entry.source) { @@ -660,7 +674,10 @@ async fn sync_one_skill( .flatten(); // Build the request — add If-None-Match if we have a cached ETag. - let client = reqwest::Client::new(); + let client = reqwest::Client::builder() + .danger_accept_invalid_certs(skip_verify) + .build() + .unwrap_or_else(|_| reqwest::Client::new()); let mut req = client.get(url); if let Some(ref meta) = existing_meta && let Some(ref etag) = meta.etag @@ -871,6 +888,7 @@ async fn candidate_urls( source: &InstallSource, network: &NetworkPolicy, registry_url: &str, + skip_verify: bool, ) -> Result { match source { InstallSource::GitHubRepo(repo) => { @@ -885,7 +903,7 @@ async fn candidate_urls( } InstallSource::DirectUrl(url) => Ok(UrlResolution::Resolved(vec![url.clone()])), InstallSource::Registry(name) => { - match fetch_registry(network, registry_url).await? { + match fetch_registry(network, registry_url, skip_verify).await? { RegistryFetchResult::Loaded(doc) => { let entry = doc .skills @@ -905,7 +923,7 @@ async fn candidate_urls( } // Reuse this function for the inner source so GitHub fallback // still applies. - Box::pin(candidate_urls(&inner, network, registry_url)).await + Box::pin(candidate_urls(&inner, network, registry_url, skip_verify)).await } RegistryFetchResult::NeedsApproval(host) => Ok(UrlResolution::NeedsApproval(host)), RegistryFetchResult::Denied(host) => Ok(UrlResolution::Denied(host)), diff --git a/crates/tui/src/tools/fetch_url.rs b/crates/tui/src/tools/fetch_url.rs index 8c76cceaf..06e9569c0 100644 --- a/crates/tui/src/tools/fetch_url.rs +++ b/crates/tui/src/tools/fetch_url.rs @@ -164,6 +164,7 @@ impl ToolSpec for FetchUrlTool { let resp = loop { let dns_pinning = validate_fetch_target(¤t_url, context).await?; let mut client_builder = reqwest::Client::builder() + .danger_accept_invalid_certs(context.insecure_skip_tls_verify) .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) .redirect(reqwest::redirect::Policy::none()); diff --git a/crates/tui/src/tools/finance.rs b/crates/tui/src/tools/finance.rs index 02331f316..2915c2dda 100644 --- a/crates/tui/src/tools/finance.rs +++ b/crates/tui/src/tools/finance.rs @@ -148,10 +148,11 @@ pub struct FinanceTool { impl FinanceTool { #[must_use] - pub fn new() -> Self { + pub fn new(skip_verify: bool) -> Self { Self { endpoints: FinanceEndpoints::default(), client: Client::builder() + .danger_accept_invalid_certs(skip_verify) .user_agent(USER_AGENT) .build() .expect("failed to build HTTP client"), @@ -159,13 +160,14 @@ impl FinanceTool { } #[cfg(test)] - fn with_endpoints(quote_base: impl Into, chart_base: impl Into) -> Self { + fn with_endpoints(quote_base: impl Into, chart_base: impl Into, skip_verify: bool) -> Self { Self { endpoints: FinanceEndpoints { quote_base: quote_base.into(), chart_base: chart_base.into(), }, client: Client::builder() + .danger_accept_invalid_certs(skip_verify) .user_agent(USER_AGENT) .build() .expect("failed to build HTTP client"), @@ -175,7 +177,7 @@ impl FinanceTool { impl Default for FinanceTool { fn default() -> Self { - Self::new() + Self::new(false) } } @@ -627,6 +629,7 @@ mod tests { FinanceTool::with_endpoints( server.uri().to_string() + "/quote", server.uri().to_string() + "/chart", + false, ) } @@ -939,7 +942,7 @@ mod tests { #[test] fn finance_schema_allows_ticker_or_symbol() { - let schema = FinanceTool::new().input_schema(); + let schema = FinanceTool::new(false).input_schema(); let any_of = schema["anyOf"] .as_array() .expect("finance schema should advertise alternate required fields"); diff --git a/crates/tui/src/tools/registry.rs b/crates/tui/src/tools/registry.rs index 6ad9f3558..1e4ed37cb 100644 --- a/crates/tui/src/tools/registry.rs +++ b/crates/tui/src/tools/registry.rs @@ -610,7 +610,7 @@ impl ToolRegistryBuilder { use super::web_search::WebSearchTool; self.with_tool(Arc::new(WebSearchTool)) .with_tool(Arc::new(FetchUrlTool)) - .with_tool(Arc::new(FinanceTool::new())) + .with_tool(Arc::new(FinanceTool::new(false))) .with_tool(Arc::new(WebRunTool)) } @@ -619,7 +619,7 @@ impl ToolRegistryBuilder { #[must_use] pub fn with_vision_tools(self, config: crate::config::VisionModelConfig) -> Self { use crate::vision::tools::ImageAnalyzeTool; - self.with_tool(Arc::new(ImageAnalyzeTool::new(config))) + self.with_tool(Arc::new(ImageAnalyzeTool::new(config, false))) } /// Previously registered the OpenAI-style `multi_tool_use.parallel` diff --git a/crates/tui/src/tools/spec.rs b/crates/tui/src/tools/spec.rs index 2f7d05952..5414f5c67 100644 --- a/crates/tui/src/tools/spec.rs +++ b/crates/tui/src/tools/spec.rs @@ -163,6 +163,10 @@ pub struct ToolContext { /// API key for Tavily or Bocha. `None` for Bing or DuckDuckGo. pub search_api_key: Option, + /// Skip TLS certificate verification for outbound HTTPS requests made by + /// network tools. Defaults to `false`. + pub insecure_skip_tls_verify: bool, + /// Per-session workshop variable store (#548). Holds the raw content of /// the most recent large-tool routing event so the parent can call /// `promote_to_context` later. `None` when the router is disabled. @@ -201,6 +205,7 @@ impl ToolContext { large_output_router: None, search_provider: crate::config::SearchProvider::default(), search_api_key: None, + insecure_skip_tls_verify: false, workshop_vars: None, } } @@ -237,6 +242,7 @@ impl ToolContext { large_output_router: None, search_provider: crate::config::SearchProvider::default(), search_api_key: None, + insecure_skip_tls_verify: false, workshop_vars: None, } } @@ -273,10 +279,18 @@ impl ToolContext { large_output_router: None, search_provider: crate::config::SearchProvider::default(), search_api_key: None, + insecure_skip_tls_verify: false, workshop_vars: None, } } + /// Skip TLS certificate verification for outbound HTTPS requests. + #[must_use] + pub fn with_insecure_skip_tls_verify(mut self, skip: bool) -> Self { + self.insecure_skip_tls_verify = skip; + self + } + /// Attach a per-domain network policy to this context (#135). #[must_use] pub fn with_network_policy(mut self, policy: NetworkPolicyDecider) -> Self { diff --git a/crates/tui/src/tools/web_run.rs b/crates/tui/src/tools/web_run.rs index 2eec13193..549790113 100644 --- a/crates/tui/src/tools/web_run.rs +++ b/crates/tui/src/tools/web_run.rs @@ -476,7 +476,7 @@ impl ToolSpec for WebRunTool { .unwrap_or_default(); let (entries, source, warning) = - run_search(&query, max_results, timeout_ms, &domains).await?; + run_search(&query, max_results, timeout_ms, &domains, context.insecure_skip_tls_verify).await?; let mut warnings = Vec::new(); if recency > 0 { warnings.push(format!( @@ -538,7 +538,7 @@ impl ToolSpec for WebRunTool { .unwrap_or_default(); let (entries, warning) = - run_image_search(&query, max_results, timeout_ms, &domains).await?; + run_image_search(&query, max_results, timeout_ms, &domains, context.insecure_skip_tls_verify).await?; let mut warnings = Vec::new(); if recency > 0 { @@ -699,7 +699,7 @@ async fn resolve_or_fetch_page( } if looks_like_url(ref_id) { check_network_policy(ref_id, context)?; - return fetch_page(ref_id, timeout_ms).await; + return fetch_page(ref_id, timeout_ms, context.insecure_skip_tls_verify).await; } Err(ToolError::invalid_input(format!( "Unknown ref_id '{ref_id}'" @@ -715,8 +715,10 @@ async fn run_search( max_results: usize, timeout_ms: u64, domains: &[String], + skip_verify: bool, ) -> Result<(Vec, String, Option), ToolError> { let client = reqwest::Client::builder() + .danger_accept_invalid_certs(skip_verify) .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) .build() @@ -911,8 +913,10 @@ async fn run_image_search( max_results: usize, timeout_ms: u64, domains: &[String], + skip_verify: bool, ) -> Result<(Vec, Option), ToolError> { let client = reqwest::Client::builder() + .danger_accept_invalid_certs(skip_verify) .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) .build() @@ -1064,8 +1068,9 @@ fn check_network_policy(url: &str, context: &ToolContext) -> Result<(), ToolErro } } -async fn fetch_page(url: &str, timeout_ms: u64) -> Result { +async fn fetch_page(url: &str, timeout_ms: u64, skip_verify: bool) -> Result { let client = reqwest::Client::builder() + .danger_accept_invalid_certs(skip_verify) .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) .build() diff --git a/crates/tui/src/tools/web_search.rs b/crates/tui/src/tools/web_search.rs index d46cac7e8..74887aeec 100644 --- a/crates/tui/src/tools/web_search.rs +++ b/crates/tui/src/tools/web_search.rs @@ -203,6 +203,7 @@ impl ToolSpec for WebSearchTool { let decider = context.network_policy.as_ref(); let client = reqwest::Client::builder() + .danger_accept_invalid_certs(context.insecure_skip_tls_verify) .timeout(Duration::from_millis(timeout_ms)) .user_agent(USER_AGENT) .build() @@ -328,6 +329,7 @@ impl WebSearchTool { })?; let client = reqwest::Client::builder() + .danger_accept_invalid_certs(context.insecure_skip_tls_verify) .timeout(Duration::from_millis(timeout_ms)) .build() .map_err(|e| { @@ -425,6 +427,7 @@ impl WebSearchTool { })?; let client = reqwest::Client::builder() + .danger_accept_invalid_certs(context.insecure_skip_tls_verify) .timeout(Duration::from_millis(timeout_ms)) .build() .map_err(|e| { diff --git a/crates/tui/src/vision/tools.rs b/crates/tui/src/vision/tools.rs index 56cc7b4e2..3a4ccfdee 100644 --- a/crates/tui/src/vision/tools.rs +++ b/crates/tui/src/vision/tools.rs @@ -20,8 +20,9 @@ pub struct ImageAnalyzeTool { impl ImageAnalyzeTool { #[must_use] - pub fn new(config: VisionModelConfig) -> Self { + pub fn new(config: VisionModelConfig, skip_verify: bool) -> Self { let client = reqwest::Client::builder() + .danger_accept_invalid_certs(skip_verify) .timeout(Duration::from_secs(120)) .build() .expect("Failed to build HTTP client"); @@ -231,7 +232,7 @@ mod tests { #[test] fn tool_metadata_is_read_only_and_named_image_analyze() { - let tool = ImageAnalyzeTool::new(fake_config()); + let tool = ImageAnalyzeTool::new(fake_config(), false); assert_eq!(tool.name(), "image_analyze"); assert!(tool.capabilities().contains(&ToolCapability::ReadOnly)); } @@ -269,7 +270,7 @@ mod tests { // before any base64 / API call. let tmp = tempdir().expect("tempdir"); let ctx = ToolContext::new(tmp.path().to_path_buf()); - let tool = ImageAnalyzeTool::new(fake_config()); + let tool = ImageAnalyzeTool::new(fake_config(), false); let outside_workspace = if cfg!(windows) { r"C:\Windows\System32\drivers\etc\hosts" } else { @@ -290,7 +291,7 @@ mod tests { async fn execute_rejects_parent_dir_traversal() { let tmp = tempdir().expect("tempdir"); let ctx = ToolContext::new(tmp.path().to_path_buf()); - let tool = ImageAnalyzeTool::new(fake_config()); + let tool = ImageAnalyzeTool::new(fake_config(), false); let err = tool .execute(json!({"image_path": "../escape.png"}), &ctx) .await