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