Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
token totals are updated. Hooks receive structured JSON with status, usage,
totals, duration, tool count, and queued-message count on stdin; stdout is
ignored and failures are warn-only (#1364, #2578).
- Added provider-scoped `insecure_skip_tls_verify` for private
OpenAI-compatible gateways that cannot use a trusted CA bundle. The setting is
disabled by default, applies only to the active LLM provider HTTP client, and
is surfaced by `codewhale doctor`; `SSL_CERT_FILE` remains the preferred path
for corporate or private CA roots. Thanks @wavezhang for the original #1893
direction.
- Added rich PlanArtifact support to `update_plan`: Plan mode can now carry
grounded objectives, context, sources, critical files, constraints,
verification, risks, and handoff notes through the transcript card, Plan
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -524,7 +524,7 @@ Key environment variables:
| `OLLAMA_MODEL` | Self-hosted Ollama model tag |
| `HUGGINGFACE_API_KEY` / `HF_TOKEN` / `HUGGINGFACE_BASE_URL` / `HUGGINGFACE_MODEL` | Hugging Face endpoint and model override |
| `NO_ANIMATIONS=1` | Force accessibility mode at startup |
| `SSL_CERT_FILE` | Custom CA bundle for corporate proxies |
| `SSL_CERT_FILE` | Custom CA bundle for corporate proxies; prefer this over provider-local `insecure_skip_tls_verify` |

Set `locale` in `settings.toml`, use `/config locale zh-Hans`, or rely on `LC_ALL`/`LANG` to choose UI chrome and the fallback language sent to V4 models. The latest user message still wins for natural-language reasoning and replies, so Chinese user turns stay Chinese even on an English system locale. See [docs/CONFIGURATION.md](docs/CONFIGURATION.md) and [docs/MCP.md](docs/MCP.md).

Expand Down
2 changes: 2 additions & 0 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,7 @@ max_subagents = 10 # optional (1-20)
# model = "deepseek-ai/DeepSeek-V4-Pro"
# http_headers = { "X-Model-Provider-Id" = "your-model-provider" } # optional custom request headers
# path_suffix = "/chat/completions" # override the API path; skips /v1 versioning when set
# insecure_skip_tls_verify = true # last resort for private gateways; prefer SSL_CERT_FILE

# NVIDIA NIM-hosted DeepSeek V4 (https://build.nvidia.com)
[providers.nvidia_nim]
Expand All @@ -292,6 +293,7 @@ max_subagents = 10 # optional (1-20)
# Gateway example:
# base_url = "https://gateway.example/v1"
# model = "your-deepseek-compatible-model"
# insecure_skip_tls_verify = true # last resort for private gateways; prefer SSL_CERT_FILE

# AtlasCloud OpenAI-compatible endpoint (https://www.atlascloud.ai/docs/models/llm)
[providers.atlascloud]
Expand Down
27 changes: 27 additions & 0 deletions crates/config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ pub struct ProviderConfigToml {
pub model: Option<String>,
pub mode: Option<String>,
pub auth_mode: Option<String>,
pub insecure_skip_tls_verify: Option<bool>,
#[serde(default)]
pub http_headers: BTreeMap<String, String>,
pub path_suffix: Option<String>,
Expand Down Expand Up @@ -1783,6 +1784,7 @@ impl ConfigToml {
api_key_source,
base_url,
auth_mode,
insecure_skip_tls_verify: provider_cfg.insecure_skip_tls_verify.unwrap_or(false),
output_mode,
log_level,
telemetry,
Expand Down Expand Up @@ -2401,6 +2403,7 @@ pub struct ResolvedRuntimeOptions {
pub api_key_source: Option<RuntimeApiKeySource>,
pub base_url: String,
pub auth_mode: Option<String>,
pub insecure_skip_tls_verify: bool,
pub output_mode: Option<String>,
pub log_level: Option<String>,
pub telemetry: bool,
Expand Down Expand Up @@ -3633,6 +3636,28 @@ mod tests {
);
}

#[test]
fn insecure_skip_tls_verify_resolves_only_for_active_provider() {
let _lock = env_lock();
let _env = EnvGuard::without_deepseek_runtime_overrides();
let mut config = ConfigToml {
provider: ProviderKind::Openai,
..ConfigToml::default()
};
config.providers.deepseek.insecure_skip_tls_verify = Some(true);

let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());

assert_eq!(resolved.provider, ProviderKind::Openai);
assert!(!resolved.insecure_skip_tls_verify);

config.providers.openai.insecure_skip_tls_verify = Some(true);
let resolved = config.resolve_runtime_options(&CliRuntimeOverrides::default());

assert_eq!(resolved.provider, ProviderKind::Openai);
assert!(resolved.insecure_skip_tls_verify);
}

#[test]
fn http_headers_env_overrides_config() {
let _lock = env_lock();
Expand Down Expand Up @@ -4058,6 +4083,7 @@ unix_socket_path = "/tmp/cw-hooks.sock"
};
project.providers.openrouter.api_key = Some("attacker-openrouter-key".to_string());
project.providers.openrouter.base_url = Some("https://evil.example/openrouter".to_string());
project.providers.openrouter.insecure_skip_tls_verify = Some(true);
project.providers.openrouter.path_suffix = Some("/attacker/chat".to_string());
project.providers.openrouter.model = Some("deepseek/deepseek-v4-pro".to_string());
project.providers.volcengine.model = Some("DeepSeek-V4-Pro".to_string());
Expand All @@ -4075,6 +4101,7 @@ unix_socket_path = "/tmp/cw-hooks.sock"
Some("user-openrouter-key")
);
assert_eq!(base.providers.openrouter.base_url, None);
assert_eq!(base.providers.openrouter.insecure_skip_tls_verify, None);
assert_eq!(
base.providers.openrouter.path_suffix.as_deref(),
Some("/chat/completions")
Expand Down
6 changes: 6 additions & 0 deletions crates/tui/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
token totals are updated. Hooks receive structured JSON with status, usage,
totals, duration, tool count, and queued-message count on stdin; stdout is
ignored and failures are warn-only (#1364, #2578).
- Added provider-scoped `insecure_skip_tls_verify` for private
OpenAI-compatible gateways that cannot use a trusted CA bundle. The setting is
disabled by default, applies only to the active LLM provider HTTP client, and
is surfaced by `codewhale doctor`; `SSL_CERT_FILE` remains the preferred path
for corporate or private CA roots. Thanks @wavezhang for the original #1893
direction.
- Added rich PlanArtifact support to `update_plan`: Plan mode can now carry
grounded objectives, context, sources, critical files, constraints,
verification, risks, and handoff notes through the transcript card, Plan
Expand Down
46 changes: 44 additions & 2 deletions crates/tui/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -585,6 +585,7 @@ impl DeepSeekClient {
let default_model = config.default_model();
let stream_idle_timeout = Duration::from_secs(config.stream_chunk_timeout_secs());
let http_headers = config.http_headers();
let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
let path_suffix = config
.provider_config_for(api_provider)
.and_then(|p| p.path_suffix.clone());
Expand All @@ -600,13 +601,24 @@ impl DeepSeekClient {
http_headers.len()
));
}
if insecure_skip_tls_verify {
logging::warn(format!(
"TLS certificate verification is disabled for provider {}; prefer SSL_CERT_FILE with a trusted custom CA bundle when possible",
api_provider.as_str()
));
}
logging::info(format!(
"Retry policy: enabled={}, max_retries={}, initial_delay={}s, max_delay={}s",
retry.enabled, retry.max_retries, retry.initial_delay, retry.max_delay
));

let http_client =
Self::build_http_client(&api_key, &http_headers, api_provider, &base_url)?;
let http_client = Self::build_http_client(
&api_key,
&http_headers,
api_provider,
&base_url,
insecure_skip_tls_verify,
)?;

Ok(Self {
http_client,
Expand All @@ -627,6 +639,7 @@ impl DeepSeekClient {
extra_headers: &HashMap<String, String>,
api_provider: ApiProvider,
base_url: &str,
insecure_skip_tls_verify: bool,
) -> Result<reqwest::Client> {
let headers = build_default_headers(api_key, extra_headers, api_provider, base_url)?;
let mut builder = crate::tls::reqwest_client_builder()
Expand All @@ -650,6 +663,9 @@ impl DeepSeekClient {
{
builder = add_extra_root_certs(builder, &cert_path);
}
if insecure_skip_tls_verify {
builder = builder.danger_accept_invalid_certs(true);
}
builder.build().map_err(Into::into)
}

Expand Down Expand Up @@ -1687,6 +1703,32 @@ mod tests {
assert!(headers.get("x-blank").is_none());
}

#[test]
fn build_http_client_accepts_default_tls_verification() {
let client = DeepSeekClient::build_http_client(
"sk-test",
&HashMap::new(),
ApiProvider::Deepseek,
crate::config::DEFAULT_DEEPSEEK_BASE_URL,
false,
);

assert!(client.is_ok());
}

#[test]
fn build_http_client_accepts_provider_scoped_tls_skip_verify() {
let client = DeepSeekClient::build_http_client(
"sk-test",
&HashMap::new(),
ApiProvider::Openai,
crate::config::DEFAULT_OPENAI_BASE_URL,
true,
);

assert!(client.is_ok());
}

#[test]
fn client_stream_idle_timeout_uses_tui_config() {
let client = DeepSeekClient::new(&Config {
Expand Down
39 changes: 39 additions & 0 deletions crates/tui/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1888,6 +1888,7 @@ pub struct ProviderConfig {
pub model: Option<String>,
pub mode: Option<String>,
pub auth_mode: Option<String>,
pub insecure_skip_tls_verify: Option<bool>,
pub http_headers: Option<HashMap<String, String>>,
pub path_suffix: Option<String>,
}
Expand Down Expand Up @@ -2271,6 +2272,13 @@ impl Config {
self.provider_config_for(self.api_provider())
}

#[must_use]
pub fn insecure_skip_tls_verify(&self) -> bool {
self.provider_config()
.and_then(|provider| provider.insecure_skip_tls_verify)
.unwrap_or(false)
}

#[must_use]
pub fn http_headers(&self) -> HashMap<String, String> {
let mut headers = self.http_headers.clone().unwrap_or_default();
Expand Down Expand Up @@ -4527,6 +4535,9 @@ fn merge_provider_config(base: ProviderConfig, override_cfg: ProviderConfig) ->
model: override_cfg.model.or(base.model),
mode: override_cfg.mode.or(base.mode),
auth_mode: override_cfg.auth_mode.or(base.auth_mode),
insecure_skip_tls_verify: override_cfg
.insecure_skip_tls_verify
.or(base.insecure_skip_tls_verify),
http_headers: override_cfg.http_headers.or(base.http_headers),
path_suffix: override_cfg.path_suffix.or(base.path_suffix),
}
Expand Down Expand Up @@ -8267,6 +8278,34 @@ http_headers = { "X-Model-Provider-Id" = "from-file" }
Ok(())
}

#[test]
fn insecure_skip_tls_verify_is_scoped_to_active_provider() {
let mut providers = ProvidersConfig::default();
providers.deepseek.insecure_skip_tls_verify = Some(true);
providers.openai.insecure_skip_tls_verify = Some(false);
let config = Config {
provider: Some("openai".to_string()),
providers: Some(providers),
..Default::default()
};

assert_eq!(config.api_provider(), ApiProvider::Openai);
assert!(!config.insecure_skip_tls_verify());
}

#[test]
fn insecure_skip_tls_verify_reads_active_provider_table() {
let mut providers = ProvidersConfig::default();
providers.openai.insecure_skip_tls_verify = Some(true);
let config = Config {
provider: Some("openai".to_string()),
providers: Some(providers),
..Default::default()
};

assert!(config.insecure_skip_tls_verify());
}

#[test]
fn xiaomi_mimo_provider_uses_documented_defaults() -> Result<()> {
let _lock = lock_test_env();
Expand Down
63 changes: 63 additions & 0 deletions crates/tui/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2490,6 +2490,11 @@ async fn run_doctor(config: &Config, workspace: &Path, config_path_override: Opt
println!(" · provider: {}", api_target.provider);
println!(" · base_url: {}", api_target.base_url);
println!(" · model: {}", api_target.model);
let tls_status = doctor_tls_status(config);
if !tls_status.certificate_verification {
println!(" ! {}", tls_status.message);
println!(" Prefer SSL_CERT_FILE with a trusted custom CA bundle when possible.");
}
let strict_tool_mode = doctor_strict_tool_mode_status(config);
let strict_icon = match strict_tool_mode.status {
"ready" => "✓".truecolor(aqua_r, aqua_g, aqua_b),
Expand Down Expand Up @@ -3281,6 +3286,7 @@ fn run_doctor_json(
});
let api_target = doctor_api_target(config);
let strict_tool_mode = doctor_strict_tool_mode_status(config);
let tls_status = doctor_tls_status(config);

let report = json!({
"version": env!("CARGO_PKG_VERSION"),
Expand All @@ -3299,6 +3305,12 @@ fn run_doctor_json(
"message": strict_tool_mode.message,
"recommended_base_url": strict_tool_mode.recommended_base_url,
},
"tls": {
"certificate_verification": tls_status.certificate_verification,
"insecure_skip_tls_verify": tls_status.insecure_skip_tls_verify,
"provider": tls_status.provider,
"message": tls_status.message,
},
"search_provider": doctor_search_provider_json(config),
"memory": memory_summary,
"mcp": mcp_summary,
Expand Down Expand Up @@ -3508,6 +3520,29 @@ fn doctor_strict_tool_mode_status(config: &Config) -> DoctorStrictToolModeStatus
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct DoctorTlsStatus {
certificate_verification: bool,
insecure_skip_tls_verify: bool,
provider: &'static str,
message: String,
}

fn doctor_tls_status(config: &Config) -> DoctorTlsStatus {
let provider = config.api_provider().as_str();
let insecure_skip_tls_verify = config.insecure_skip_tls_verify();
DoctorTlsStatus {
certificate_verification: !insecure_skip_tls_verify,
insecure_skip_tls_verify,
provider,
message: if insecure_skip_tls_verify {
format!("TLS certificate verification disabled for provider {provider}")
} else {
"TLS certificate verification enabled".to_string()
},
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum DeepSeekBaseUrlKind {
Beta,
Expand Down Expand Up @@ -6297,6 +6332,34 @@ mod doctor_endpoint_tests {
assert!(status.message.contains("custom endpoint"));
}

#[test]
fn doctor_tls_status_reports_verification_enabled_by_default() {
let status = doctor_tls_status(&Config::default());

assert!(status.certificate_verification);
assert!(!status.insecure_skip_tls_verify);
assert_eq!(status.provider, "deepseek");
assert!(status.message.contains("enabled"));
}

#[test]
fn doctor_tls_status_warns_when_active_provider_skips_verification() {
let mut providers = crate::config::ProvidersConfig::default();
providers.openai.insecure_skip_tls_verify = Some(true);
let config = Config {
provider: Some("openai".to_string()),
providers: Some(providers),
..Default::default()
};

let status = doctor_tls_status(&config);

assert!(!status.certificate_verification);
assert!(status.insecure_skip_tls_verify);
assert_eq!(status.provider, "openai");
assert!(status.message.contains("disabled"));
}

#[test]
fn provider_capability_report_exposes_alias_deprecation_for_deepseek_chat() {
let config = Config {
Expand Down
3 changes: 1 addition & 2 deletions crates/tui/src/prompts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1306,8 +1306,7 @@ mod tests {

assert!(set_static_prompt_composer(&cell, first).is_ok());
let rejected = set_static_prompt_composer(&cell, second)
.err()
.expect("second composer should be rejected");
.expect_err("second composer should be rejected");
let ctx = StaticPromptCtx {
model_id: "deepseek-v4-pro",
personality: Personality::Calm,
Expand Down
Loading
Loading