diff --git a/.cspell/words.txt b/.cspell/words.txt index b8d7e0b8..1e28132f 100644 --- a/.cspell/words.txt +++ b/.cspell/words.txt @@ -7,8 +7,8 @@ anthropics anyhow APFS atuin +cachain catppuccin -Catppuccin claudemd Clawd clippy @@ -87,6 +87,7 @@ Tera thiserror throbber tildified +tildifies tildify TOCTOU tokio diff --git a/CLAUDE.md b/CLAUDE.md index 5265bff7..9a7fa3b5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -160,9 +160,10 @@ ox # Start an interactive session ├── fs.rs # Filesystem helpers: `create_private_dir_all` (0o700) + `atomic_write_private` (0o600 temp+rename) ├── lock.rs # Async retry helper for advisory locks (used by oauth) ├── log.rs # `tracing` subscriber init: file under $XDG_STATE_HOME in TUI mode, stderr otherwise - ├── path.rs # Path display helpers (`tildify`: rewrite $HOME prefix as ~/) + ├── path.rs # Path display + expansion helpers (`tildify`: $HOME → ~/, `expand_user`: ~/ → $HOME) ├── text.rs # Display-width-aware text helpers (`truncate_to_width`, `ELLIPSIS`) - └── time.rs # Process-wide local-offset cache (`init_local_offset` at startup, `local_offset` reads) + ├── time.rs # Process-wide local-offset cache (`init_local_offset` at startup, `local_offset` reads) + └── tls.rs # `load_extra_ca_certs`: parse a PEM bundle into `reqwest::Certificate`s for trust-store append ``` ## Documentation diff --git a/crates/oxide-code/src/client/anthropic.rs b/crates/oxide-code/src/client/anthropic.rs index 866ed745..3602e865 100644 --- a/crates/oxide-code/src/client/anthropic.rs +++ b/crates/oxide-code/src/client/anthropic.rs @@ -27,6 +27,7 @@ use crate::config::{Auth, CompactionConfig, Config, Effort}; use crate::message::{ContentBlock, Message, Role}; use crate::prompt::SYSTEM_PROMPT_DYNAMIC_BOUNDARY; use crate::tool::ToolDefinition; +use crate::util::tls::apply_extra_ca_certs; use betas::{compute_betas, static_prefix_cache_control}; use sse::stream_sse; @@ -122,13 +123,13 @@ impl Client { headers.insert("x-stainless-retry-count", HeaderValue::from_static("0")); // No whole-request timeout — responses can run for minutes. The 60 s read timeout - // catches slowloris dribble; Anthropic sends keepalives every ~15 s on healthy streams. - let http = reqwest::Client::builder() + // catches slowloris dribble. Anthropic sends keepalives every ~15 s on healthy streams. + let builder = reqwest::Client::builder() .default_headers(headers) .connect_timeout(Duration::from_secs(15)) - .read_timeout(Duration::from_mins(1)) - .build() - .context("failed to build HTTP client")?; + .read_timeout(Duration::from_mins(1)); + let builder = apply_extra_ca_certs(builder, config.extra_ca_certs.as_deref())?; + let http = builder.build().context("failed to build HTTP client")?; Ok(Self { http, @@ -523,6 +524,20 @@ mod tests { assert_eq!(client.session_id(), sid); } + #[test] + fn new_with_extra_ca_certs_trusts_them() { + // The assertion only confirms that `Client::new` returns Ok. A client that forgot to + // reassign the builder back after `add_root_certificate` would still pass. Keeping the + // loose check here since a stronger test would need a TLS-terminating mock server + // signed by `TEST_CA_PEM`, which is heavy machinery for one line of wiring. + let pem = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut pem.as_file(), crate::util::tls::TEST_CA_PEM.as_bytes()) + .unwrap(); + let mut cfg = test_config(OFFLINE_URL, api_key(), TEST_MODEL); + cfg.extra_ca_certs = Some(pem.path().to_path_buf()); + Client::new(cfg, None).expect("valid CA bundle must build a client"); + } + #[test] fn new_rejects_auth_values_containing_invalid_header_bytes() { // `HeaderValue::from_str` rejects control chars (\n, \r); both auth arms must propagate. @@ -544,6 +559,18 @@ mod tests { } } + #[test] + fn new_surfaces_extra_ca_certs_error_with_path() { + // An unreadable trust anchor must fail the client build and cite the configured path so + // the user can debug without having to spelunk into TLS internals. + let mut cfg = test_config(OFFLINE_URL, api_key(), TEST_MODEL); + cfg.extra_ca_certs = Some(std::path::PathBuf::from("/no/such/bundle.pem")); + let err = Client::new(cfg, None).err().expect("missing CA must error"); + let msg = format!("{err:#}"); + assert!(msg.contains("failed to read extra CA bundle"), "{msg}"); + assert!(msg.contains("/no/such/bundle.pem"), "{msg}"); + } + // ── Client::set_session_id ── #[tokio::test] diff --git a/crates/oxide-code/src/client/anthropic/testing.rs b/crates/oxide-code/src/client/anthropic/testing.rs index aa8e0c72..6b778ced 100644 --- a/crates/oxide-code/src/client/anthropic/testing.rs +++ b/crates/oxide-code/src/client/anthropic/testing.rs @@ -11,6 +11,7 @@ pub(crate) fn test_config(base_url: impl Into, auth: Auth, model: &str) Config { auth, base_url: base_url.into(), + extra_ca_certs: None, model: model.to_owned(), effort: None, max_tokens: 128, diff --git a/crates/oxide-code/src/config.rs b/crates/oxide-code/src/config.rs index 7532d89e..85007a48 100644 --- a/crates/oxide-code/src/config.rs +++ b/crates/oxide-code/src/config.rs @@ -7,6 +7,7 @@ pub(crate) mod file; mod oauth; use std::fmt; +use std::path::PathBuf; use std::str::FromStr; use anyhow::{Context, Result, bail}; @@ -14,6 +15,7 @@ use serde::{Deserialize, Serialize}; use crate::tui::theme::{self, Theme}; use crate::util::env; +use crate::util::path::expand_user; const DEFAULT_MODEL: &str = "claude-opus-4-7[1m]"; const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; @@ -51,6 +53,8 @@ pub(crate) struct ConfigSnapshot { pub(crate) effort: Option, pub(crate) auth_label: &'static str, pub(crate) base_url: String, + /// User-config path appended to reqwest's trust anchors. + pub(crate) extra_ca_certs: Option, pub(crate) max_tokens: u32, /// `None` means the agent loop runs without a per-turn round cap. pub(crate) max_tool_rounds: Option, @@ -271,6 +275,9 @@ pub(crate) struct Config { pub(crate) effort: Option, pub(crate) auth: Auth, pub(crate) base_url: String, + /// Resolved path to a PEM bundle appended to reqwest's trust anchors. `None` means the + /// client keeps only the built-in Mozilla roots. + pub(crate) extra_ca_certs: Option, pub(crate) max_tokens: u32, /// `None` means the agent loop runs without a per-turn round cap. pub(crate) max_tool_rounds: Option, @@ -296,10 +303,18 @@ impl Config { let tui = fc.tui.unwrap_or_default(); let theme_config = tui.theme.unwrap_or_default(); + // Resolve before `auth` so the OAuth refresh can also thread the extra trust anchors + // through reqwest (relevant under SSL-inspecting corporate proxies). + let extra_ca_certs = env::string("OX_EXTRA_CA_CERTS") + .or(client.extra_ca_certs) + .map(|raw| expand_user(&raw)) + .transpose() + .context("invalid client.extra_ca_certs")?; + let auth = if let Some(key) = env::string("ANTHROPIC_API_KEY").or(client.api_key) { Auth::ApiKey(key) } else { - let token = oauth::load_token().await.context( + let token = oauth::load_token(extra_ca_certs.as_deref()).await.context( "no credentials available: set ANTHROPIC_API_KEY, add `api_key` to \ ~/.config/ox/config.toml, or sign in with Claude Code (checks macOS Keychain and \ ~/.claude/.credentials.json)", @@ -378,6 +393,7 @@ impl Config { effort, auth, base_url, + extra_ca_certs, max_tokens, max_tool_rounds, prompt_cache_ttl, @@ -397,6 +413,7 @@ impl Config { effort: self.effort, auth_label: self.auth.label(), base_url: self.base_url.clone(), + extra_ca_certs: self.extra_ca_certs.clone(), max_tokens: self.max_tokens, max_tool_rounds: self.max_tool_rounds, prompt_cache_ttl: self.prompt_cache_ttl, @@ -692,6 +709,7 @@ mod tests { "ANTHROPIC_BASE_URL", "ANTHROPIC_MAX_TOKENS", "ANTHROPIC_EFFORT", + "OX_EXTRA_CA_CERTS", "OX_MAX_TOOL_ROUNDS", "OX_COMPACTION_AUTO_ENABLED", "OX_COMPACTION_AUTO_THRESHOLD_PERCENT", @@ -1153,6 +1171,122 @@ mod tests { ); } + // ── Config::load / extra_ca_certs ── + + #[tokio::test] + async fn load_extra_ca_certs_default_is_none() { + let dir = tempfile::tempdir().unwrap(); + let config = temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load()) + .await + .unwrap(); + assert!(config.extra_ca_certs.is_none()); + } + + #[tokio::test] + async fn load_extra_ca_certs_env_beats_file_and_expands_tilde() { + let dir = tempfile::tempdir().unwrap(); + write_user_config( + dir.path(), + indoc::indoc! {r#" + [client] + extra_ca_certs = "/etc/ssl/from-file.pem" + "#}, + ); + // Env supplies a `~`-prefixed path so the expansion branch is also covered. + let vars = env_vars(vec![xdg(&dir), env("OX_EXTRA_CA_CERTS", "~/certs/env.pem")]); + let config = temp_env::async_with_vars(vars, Config::load()) + .await + .unwrap(); + let home = dirs::home_dir().expect("HOME set"); + assert_eq!(config.extra_ca_certs, Some(home.join("certs/env.pem"))); + } + + #[tokio::test] + async fn load_extra_ca_certs_file_used_when_env_unset() { + let dir = tempfile::tempdir().unwrap(); + write_user_config( + dir.path(), + indoc::indoc! {r#" + [client] + extra_ca_certs = "/etc/ssl/from-file.pem" + "#}, + ); + let config = temp_env::async_with_vars(env_vars(vec![xdg(&dir)]), Config::load()) + .await + .unwrap(); + assert_eq!( + config.extra_ca_certs, + Some(PathBuf::from("/etc/ssl/from-file.pem")), + ); + } + + #[tokio::test] + async fn load_extra_ca_certs_file_wins_when_env_is_empty() { + // `env::string` treats an empty env var as absent, so a file value must survive even + // when the caller exports `OX_EXTRA_CA_CERTS=""` (e.g., by unsetting via shell). + let dir = tempfile::tempdir().unwrap(); + write_user_config( + dir.path(), + indoc::indoc! {r#" + [client] + extra_ca_certs = "/etc/ssl/from-file.pem" + "#}, + ); + let vars = env_vars(vec![xdg(&dir), env("OX_EXTRA_CA_CERTS", "")]); + let config = temp_env::async_with_vars(vars, Config::load()) + .await + .unwrap(); + assert_eq!( + config.extra_ca_certs, + Some(PathBuf::from("/etc/ssl/from-file.pem")), + ); + } + + // ── Config::load / base URL validation ── + + #[tokio::test] + async fn load_rejects_plain_http_base_url_unless_loopback() { + let dir = tempfile::tempdir().unwrap(); + let vars = env_vars(vec![ + xdg(&dir), + env("ANTHROPIC_BASE_URL", "http://example.com"), + ]); + let err = temp_env::async_with_vars(vars, Config::load()) + .await + .expect_err("non-loopback http must be rejected"); + let msg = format!("{err:#}"); + assert!(msg.contains("https"), "{msg}"); + assert!(msg.contains("localhost"), "{msg}"); + } + + #[tokio::test] + async fn load_rejects_non_http_base_url_scheme() { + let dir = tempfile::tempdir().unwrap(); + let vars = env_vars(vec![ + xdg(&dir), + env("ANTHROPIC_BASE_URL", "ftp://example.com"), + ]); + let err = temp_env::async_with_vars(vars, Config::load()) + .await + .expect_err("non-http schemes must be rejected"); + let msg = format!("{err:#}"); + assert!(msg.contains("http or https"), "{msg}"); + assert!(msg.contains("ftp"), "{msg}"); + } + + #[tokio::test] + async fn load_accepts_loopback_http_base_url_for_local_proxy() { + let dir = tempfile::tempdir().unwrap(); + let vars = env_vars(vec![ + xdg(&dir), + env("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"), + ]); + let config = temp_env::async_with_vars(vars, Config::load()) + .await + .unwrap(); + assert_eq!(config.base_url, "http://127.0.0.1:8080"); + } + // ── Config::load / effort resolution ── #[tokio::test] @@ -1263,51 +1397,6 @@ mod tests { assert!(msg.contains("insane"), "{msg}"); } - // ── Config::load / base URL validation ── - - #[tokio::test] - async fn load_rejects_plain_http_base_url_unless_loopback() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_BASE_URL", "http://example.com"), - ]); - let err = temp_env::async_with_vars(vars, Config::load()) - .await - .expect_err("non-loopback http must be rejected"); - let msg = format!("{err:#}"); - assert!(msg.contains("https"), "{msg}"); - assert!(msg.contains("localhost"), "{msg}"); - } - - #[tokio::test] - async fn load_rejects_non_http_base_url_scheme() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_BASE_URL", "ftp://example.com"), - ]); - let err = temp_env::async_with_vars(vars, Config::load()) - .await - .expect_err("non-http schemes must be rejected"); - let msg = format!("{err:#}"); - assert!(msg.contains("http or https"), "{msg}"); - assert!(msg.contains("ftp"), "{msg}"); - } - - #[tokio::test] - async fn load_accepts_loopback_http_base_url_for_local_proxy() { - let dir = tempfile::tempdir().unwrap(); - let vars = env_vars(vec![ - xdg(&dir), - env("ANTHROPIC_BASE_URL", "http://127.0.0.1:8080"), - ]); - let config = temp_env::async_with_vars(vars, Config::load()) - .await - .unwrap(); - assert_eq!(config.base_url, "http://127.0.0.1:8080"); - } - // ── Config::load / prompt_cache_ttl ── #[tokio::test] @@ -1369,10 +1458,11 @@ mod tests { #[test] fn snapshot_copies_every_user_facing_field_and_drops_secret() { - // `/config` prints from the snapshot; secret must reduce to `label()`. + // `/config` prints from the snapshot, so the secret must reduce to `label()`. let cfg = Config { auth: Auth::OAuth("token-must-not-leak".to_owned()), base_url: "https://api.example.test".to_owned(), + extra_ca_certs: Some(PathBuf::from("/etc/ssl/corp-ca.pem")), model: "claude-test-1-0".to_owned(), effort: Some(Effort::Xhigh), max_tokens: 64_000, @@ -1391,6 +1481,10 @@ mod tests { let snap = cfg.snapshot(); assert_eq!(snap.auth_label, "OAuth"); assert_eq!(snap.base_url, "https://api.example.test"); + assert_eq!( + snap.extra_ca_certs.as_deref(), + Some(Path::new("/etc/ssl/corp-ca.pem")), + ); assert_eq!(snap.model_id, "claude-test-1-0"); assert_eq!(snap.effort, Some(Effort::Xhigh)); assert_eq!(snap.max_tokens, 64_000); diff --git a/crates/oxide-code/src/config/file.rs b/crates/oxide-code/src/config/file.rs index ee1f58f5..78cd73e3 100644 --- a/crates/oxide-code/src/config/file.rs +++ b/crates/oxide-code/src/config/file.rs @@ -27,8 +27,11 @@ pub(super) struct FileConfig { #[derive(Debug, Default, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct ClientConfig { + // Any new field that influences credentials, endpoint identity, or TLS trust must also be + // listed in `reject_project_secrets` so a checked-in `ox.toml` cannot set it. pub(super) api_key: Option, pub(super) base_url: Option, + pub(super) extra_ca_certs: Option, pub(super) model: Option, pub(super) effort: Option, pub(super) max_tokens: Option, @@ -80,6 +83,7 @@ impl ClientConfig { Self { api_key: other.api_key.or(self.api_key), base_url: other.base_url.or(self.base_url), + extra_ca_certs: other.extra_ca_certs.or(self.extra_ca_certs), model: other.model.or(self.model), effort: other.effort.or(self.effort), max_tokens: other.max_tokens.or(self.max_tokens), @@ -183,6 +187,9 @@ fn reject_project_secrets(config: &FileConfig, path: &Path) -> Result<()> { if client.base_url.is_some() { blocked.push("client.base_url"); } + if client.extra_ca_certs.is_some() { + blocked.push("client.extra_ca_certs"); + } if blocked.is_empty() { return Ok(()); } @@ -251,6 +258,7 @@ mod tests { client: Some(ClientConfig { api_key: Some("base-key".to_owned()), base_url: Some("https://base.example.com".to_owned()), + extra_ca_certs: Some("/etc/ssl/base.pem".to_owned()), model: Some("base-model".to_owned()), effort: Some(super::super::Effort::Low), max_tokens: Some(1000), @@ -272,6 +280,7 @@ mod tests { client: Some(ClientConfig { api_key: Some("other-key".to_owned()), base_url: Some("https://other.example.com".to_owned()), + extra_ca_certs: Some("/etc/ssl/other.pem".to_owned()), model: Some("other-model".to_owned()), effort: Some(super::super::Effort::Max), max_tokens: Some(2000), @@ -297,6 +306,7 @@ mod tests { client.base_url.as_deref(), Some("https://other.example.com") ); + assert_eq!(client.extra_ca_certs.as_deref(), Some("/etc/ssl/other.pem")); assert_eq!(client.model.as_deref(), Some("other-model")); assert_eq!(client.effort, Some(super::super::Effort::Max)); assert_eq!(client.max_tokens, Some(2000)); @@ -339,6 +349,7 @@ mod tests { client: Some(ClientConfig { api_key: Some("key".to_owned()), base_url: Some("https://example.com".to_owned()), + extra_ca_certs: Some("/etc/ssl/ca.pem".to_owned()), model: Some("model".to_owned()), effort: Some(super::super::Effort::High), max_tokens: Some(4096), @@ -361,6 +372,7 @@ mod tests { let client = merged.client.expect("client section should survive"); assert_eq!(client.api_key.as_deref(), Some("key")); assert_eq!(client.base_url.as_deref(), Some("https://example.com")); + assert_eq!(client.extra_ca_certs.as_deref(), Some("/etc/ssl/ca.pem")); assert_eq!(client.model.as_deref(), Some("model")); assert_eq!(client.effort, Some(super::super::Effort::High)); assert_eq!(client.max_tokens, Some(4096)); @@ -467,6 +479,75 @@ mod tests { assert!(map.contains_key("error")); } + // ── load_project_file ── + + #[test] + fn load_project_file_rejects_trust_establishing_client_fields() { + // `api_key`, `base_url`, and `extra_ca_certs` all influence who receives or is trusted + // to be the server, so a checked-in `ox.toml` cannot set them. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(PROJECT_CONFIG_FILENAME); + std::fs::write( + &path, + indoc! {r#" + [client] + api_key = "sk-project" + base_url = "https://capture.invalid" + extra_ca_certs = "./attacker-ca.pem" + "#}, + ) + .unwrap(); + + let err = load_project_file(&path).expect_err("project secrets must be blocked"); + let msg = format!("{err:#}"); + assert!(msg.contains("client.api_key"), "{msg}"); + assert!(msg.contains("client.base_url"), "{msg}"); + assert!(msg.contains("client.extra_ca_certs"), "{msg}"); + assert!(msg.contains("~/.config/ox/config.toml"), "{msg}"); + } + + #[test] + fn load_project_file_allows_non_secret_client_settings() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(PROJECT_CONFIG_FILENAME); + std::fs::write( + &path, + indoc! {r#" + [client] + model = "claude-sonnet-4-6" + max_tokens = 8192 + "#}, + ) + .unwrap(); + + let config = load_project_file(&path) + .expect("project settings should parse") + .expect("file exists"); + let client = config.client.expect("client section present"); + assert_eq!(client.model.as_deref(), Some("claude-sonnet-4-6")); + assert_eq!(client.max_tokens, Some(8192)); + } + + #[test] + fn load_project_file_allows_tui_only_settings() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(PROJECT_CONFIG_FILENAME); + std::fs::write( + &path, + indoc! {" + [tui] + show_welcome = false + "}, + ) + .unwrap(); + + let config = load_project_file(&path) + .expect("project UI settings should parse") + .expect("file exists"); + assert!(config.client.is_none()); + assert_eq!(config.tui.unwrap().show_welcome, Some(false)); + } + // ── load_file ── #[test] @@ -623,71 +704,6 @@ mod tests { assert!(msg.contains("unknown field `show_thinking`"), "{msg}"); } - // ── load_project_file ── - - #[test] - fn load_project_file_rejects_api_key_and_base_url() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(PROJECT_CONFIG_FILENAME); - std::fs::write( - &path, - indoc! {r#" - [client] - api_key = "sk-project" - base_url = "https://capture.invalid" - "#}, - ) - .unwrap(); - - let err = load_project_file(&path).expect_err("project secrets must be blocked"); - let msg = format!("{err:#}"); - assert!(msg.contains("client.api_key"), "{msg}"); - assert!(msg.contains("client.base_url"), "{msg}"); - assert!(msg.contains("~/.config/ox/config.toml"), "{msg}"); - } - - #[test] - fn load_project_file_allows_non_secret_client_settings() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(PROJECT_CONFIG_FILENAME); - std::fs::write( - &path, - indoc! {r#" - [client] - model = "claude-sonnet-4-6" - max_tokens = 8192 - "#}, - ) - .unwrap(); - - let config = load_project_file(&path) - .expect("project settings should parse") - .expect("file exists"); - let client = config.client.expect("client section present"); - assert_eq!(client.model.as_deref(), Some("claude-sonnet-4-6")); - assert_eq!(client.max_tokens, Some(8192)); - } - - #[test] - fn load_project_file_allows_tui_only_settings() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join(PROJECT_CONFIG_FILENAME); - std::fs::write( - &path, - indoc! {" - [tui] - show_welcome = false - "}, - ) - .unwrap(); - - let config = load_project_file(&path) - .expect("project UI settings should parse") - .expect("file exists"); - assert!(config.client.is_none()); - assert_eq!(config.tui.unwrap().show_welcome, Some(false)); - } - // ── find_project_config_from ── #[test] diff --git a/crates/oxide-code/src/config/oauth.rs b/crates/oxide-code/src/config/oauth.rs index 6f26b494..30821ee2 100644 --- a/crates/oxide-code/src/config/oauth.rs +++ b/crates/oxide-code/src/config/oauth.rs @@ -11,6 +11,7 @@ use tracing::{debug, warn}; use crate::util::env; use crate::util::fs::atomic_write_private; use crate::util::lock; +use crate::util::tls::apply_extra_ca_certs; const OAUTH_TOKEN_URL: &str = "https://platform.claude.com/v1/oauth/token"; const OAUTH_CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"; @@ -56,16 +57,24 @@ impl OAuthCredential { /// Loads (and refreshes if near expiry) the Claude Code OAuth access token. On macOS the Keychain /// is consulted first; non-macOS and Keychain-miss fall back to `~/.claude/.credentials.json`. -pub(super) async fn load_token() -> Result { +pub(super) async fn load_token(extra_ca_certs: Option<&Path>) -> Result { let file_path = credentials_path().context("could not determine home directory")?; let lock_path = lock_path().context("could not determine home directory")?; - load_token_from(&file_path, &lock_path, OAUTH_TOKEN_URL, load_credentials).await + load_token_from( + &file_path, + &lock_path, + OAUTH_TOKEN_URL, + extra_ca_certs, + load_credentials, + ) + .await } async fn load_token_from( file_path: &Path, lock_path: &Path, refresh_url: &str, + extra_ca_certs: Option<&Path>, loader: fn(&Path) -> Result, ) -> Result { let oauth = loader(file_path)?.claude_ai_oauth; @@ -97,7 +106,7 @@ async fn load_token_from( .as_deref() .context("refresh token missing after re-read")?; - match refresh_oauth_token(refresh_url, refresh_token).await { + match refresh_oauth_token(refresh_url, refresh_token, extra_ca_certs).await { Ok(response) => { write_refreshed_credentials(file_path, &response)?; Ok(response.access_token) @@ -199,10 +208,16 @@ struct RefreshRequest<'a> { scope: &'a str, } -async fn refresh_oauth_token(url: &str, refresh_token: &str) -> Result { - let client = reqwest::Client::builder() - .timeout(REFRESH_TIMEOUT) - .build()?; +async fn refresh_oauth_token( + url: &str, + refresh_token: &str, + extra_ca_certs: Option<&Path>, +) -> Result { + let builder = reqwest::Client::builder().timeout(REFRESH_TIMEOUT); + let builder = apply_extra_ca_certs(builder, extra_ca_certs)?; + let client = builder + .build() + .context("failed to build OAuth HTTP client")?; let scope = OAUTH_SCOPES.join(" "); let response = client @@ -398,7 +413,7 @@ mod tests { let token = temp_env::async_with_vars( [("HOME", Some(home.path().to_string_lossy().into_owned()))], - async { load_token().await.unwrap() }, + async { load_token(None).await.unwrap() }, ) .await; assert_eq!(token, "token-from-home"); @@ -429,6 +444,7 @@ mod tests { &creds, &lock, "http://should-not-be-called", + None, read_credentials, ) .await @@ -447,6 +463,7 @@ mod tests { &creds, &lock, "http://should-not-be-called", + None, read_credentials, ) .await @@ -461,7 +478,7 @@ mod tests { let lock = dir.path().join("lock"); write_creds(&creds, "tok", None, 0); - let err = load_token_from(&creds, &lock, "http://unused", read_credentials) + let err = load_token_from(&creds, &lock, "http://unused", None, read_credentials) .await .expect_err("expired without refresh must bail"); assert!(format!("{err:#}").contains("expired")); @@ -490,7 +507,7 @@ mod tests { now_millis().unwrap() + 1_000, ); - let token = load_token_from(&creds, &lock, &server.uri(), read_credentials) + let token = load_token_from(&creds, &lock, &server.uri(), None, read_credentials) .await .unwrap(); assert_eq!(token, "fresh-access"); @@ -526,7 +543,7 @@ mod tests { now_millis().unwrap() + 60_000, ); - let token = load_token_from(&creds, &lock, &server.uri(), read_credentials) + let token = load_token_from(&creds, &lock, &server.uri(), None, read_credentials) .await .unwrap(); assert_eq!(token, "stale"); @@ -546,7 +563,7 @@ mod tests { let lock = dir.path().join("lock"); write_creds(&creds, "dead", Some("old"), 0); - let err = load_token_from(&creds, &lock, &server.uri(), read_credentials) + let err = load_token_from(&creds, &lock, &server.uri(), None, read_credentials) .await .expect_err("expired + refresh down must bail"); let msg = format!("{err:#}"); @@ -676,7 +693,7 @@ mod tests { .mount(&server) .await; - let response = refresh_oauth_token(&server.uri(), "old-refresh") + let response = refresh_oauth_token(&server.uri(), "old-refresh", None) .await .unwrap(); assert_eq!(response.access_token, "new-access"); @@ -705,7 +722,7 @@ mod tests { .mount(&server) .await; - let err = refresh_oauth_token(&server.uri(), "bad") + let err = refresh_oauth_token(&server.uri(), "bad", None) .await .expect_err("expected HTTP error"); let msg = format!("{err:#}"); @@ -722,12 +739,50 @@ mod tests { .mount(&server) .await; - let err = refresh_oauth_token(&server.uri(), "tok") + let err = refresh_oauth_token(&server.uri(), "tok", None) .await .expect_err("expected parse error"); assert!(format!("{err:#}").contains("parse")); } + #[tokio::test] + async fn refresh_oauth_token_honors_extra_ca_certs() { + // A valid PEM bundle must thread through the client builder without disrupting the + // existing request flow. Pairs with the surfaces-loader-error test below. + let server = MockServer::start().await; + Mock::given(method("POST")) + .and(wm_path("/")) + .respond_with(ResponseTemplate::new(200).set_body_json(ok_refresh_body( + "new-access", + "new-refresh", + 3600, + ))) + .mount(&server) + .await; + + let pem = tempfile::NamedTempFile::new().unwrap(); + std::io::Write::write_all(&mut pem.as_file(), crate::util::tls::TEST_CA_PEM.as_bytes()) + .unwrap(); + + let response = refresh_oauth_token(&server.uri(), "refresh", Some(pem.path())) + .await + .expect("valid PEM must build a client and produce a response"); + assert_eq!(response.access_token, "new-access"); + } + + #[tokio::test] + async fn refresh_oauth_token_surfaces_extra_ca_certs_error_with_path() { + // An unreadable bundle must fail before any network call, citing the path so the user + // can debug the configuration rather than chase a TLS error downstream. + let missing = Path::new("/definitely/does/not/exist.pem"); + let err = refresh_oauth_token("http://unused.invalid/", "refresh", Some(missing)) + .await + .expect_err("missing CA bundle must error"); + let msg = format!("{err:#}"); + assert!(msg.contains("failed to read extra CA bundle"), "{msg}"); + assert!(msg.contains("/definitely/does/not/exist.pem"), "{msg}"); + } + // ── write_refreshed_credentials ── #[test] diff --git a/crates/oxide-code/src/slash.rs b/crates/oxide-code/src/slash.rs index dcf536b1..899f85f1 100644 --- a/crates/oxide-code/src/slash.rs +++ b/crates/oxide-code/src/slash.rs @@ -132,6 +132,7 @@ pub(crate) fn test_session_info() -> LiveSessionInfo { config: ConfigSnapshot { auth_label: "API key", base_url: "https://api.test.invalid".to_owned(), + extra_ca_certs: None, model_id: "claude-opus-4-7".to_owned(), effort: Some(Effort::High), max_tokens: 32_000, diff --git a/crates/oxide-code/src/slash/config.rs b/crates/oxide-code/src/slash/config.rs index c9a22a0f..f4a12d57 100644 --- a/crates/oxide-code/src/slash/config.rs +++ b/crates/oxide-code/src/slash/config.rs @@ -51,6 +51,12 @@ fn build_modal( ("Effort".to_owned(), display_effort(cfg.effort)), ("Auth".to_owned(), cfg.auth_label.to_owned()), ("Base URL".to_owned(), cfg.base_url.clone()), + ( + "Extra CA Certs".to_owned(), + cfg.extra_ca_certs + .as_deref() + .map_or_else(|| "(none)".to_owned(), tildify), + ), ("Max Tokens".to_owned(), cfg.max_tokens.to_string()), ( "Max Tool Rounds".to_owned(), @@ -155,11 +161,11 @@ mod tests { #[test] fn build_modal_height_accounts_for_both_sections() { - // title + blank + (heading + blank + 10 rows) + blank + (heading + blank + 2 rows) - // + blank + footer = 2 + 12 + 1 + 4 + 2 = 21. + // title + blank + (heading + blank + 11 rows) + blank + (heading + blank + 2 rows) + // + blank + footer = 2 + 13 + 1 + 4 + 2 = 22. let info = test_session_info(); let m = build_modal(&info, None, None); - assert_eq!(m.height(80), 21); + assert_eq!(m.height(80), 22); } #[test] @@ -172,6 +178,31 @@ mod tests { assert!(rendered.contains("at 155000 tokens"), "{rendered}"); } + #[test] + fn build_modal_tildifies_extra_ca_certs_path() { + // Pin HOME so the test is deterministic regardless of the runner env. + temp_env::with_var("HOME", Some("/tmp/oxide-fake-home"), || { + let mut info = test_session_info(); + info.config.extra_ca_certs = Some(PathBuf::from("/tmp/oxide-fake-home/certs/corp.pem")); + let m = build_modal(&info, None, None); + let rendered = render_modal(&m, 80); + + assert!(rendered.contains("Extra CA Certs"), "{rendered}"); + assert!(rendered.contains("~/certs/corp.pem"), "{rendered}"); + }); + } + + #[test] + fn build_modal_renders_extra_ca_certs_none_as_placeholder() { + let info = test_session_info(); + assert!(info.config.extra_ca_certs.is_none(), "precondition: unset"); + let m = build_modal(&info, None, None); + let rendered = render_modal(&m, 80); + + assert!(rendered.contains("Extra CA Certs"), "{rendered}"); + assert!(rendered.contains("(none)"), "{rendered}"); + } + // ── display_path ── #[test] diff --git a/crates/oxide-code/src/tui/app.rs b/crates/oxide-code/src/tui/app.rs index ea706967..81082d08 100644 --- a/crates/oxide-code/src/tui/app.rs +++ b/crates/oxide-code/src/tui/app.rs @@ -933,6 +933,7 @@ mod tests { config: ConfigSnapshot { auth_label: "API key", base_url: "https://api.test.invalid".to_owned(), + extra_ca_certs: None, model_id: "test-model".to_owned(), effort: Some(Effort::High), max_tokens: 32_000, diff --git a/crates/oxide-code/src/tui/components/welcome.rs b/crates/oxide-code/src/tui/components/welcome.rs index 2176c73b..d310719d 100644 --- a/crates/oxide-code/src/tui/components/welcome.rs +++ b/crates/oxide-code/src/tui/components/welcome.rs @@ -342,6 +342,7 @@ mod tests { config: ConfigSnapshot { auth_label: "OAuth", base_url: "https://api.test.invalid".to_owned(), + extra_ca_certs: None, model_id: "claude-opus-4-7".to_owned(), effort: Some(Effort::Xhigh), max_tokens: 64_000, diff --git a/crates/oxide-code/src/tui/theme/loader.rs b/crates/oxide-code/src/tui/theme/loader.rs index 93fb43cb..fb5e2f40 100644 --- a/crates/oxide-code/src/tui/theme/loader.rs +++ b/crates/oxide-code/src/tui/theme/loader.rs @@ -14,7 +14,6 @@ //! so the TUI still launches. use std::collections::HashMap; -use std::path::PathBuf; use anyhow::{Context, Result, bail}; use ratatui::style::Modifier; @@ -54,7 +53,7 @@ fn load_base_body(name: &str) -> Result { if let Some(body) = builtin::lookup(name) { return Ok(body.to_owned()); } - let path = expand_tilde(name); + let path = crate::util::path::expand_user(name).with_context(|| format!("theme {name:?}"))?; std::fs::read_to_string(&path).with_context(|| { format!( "theme {name:?}: not a built-in name and failed to read as file {}", @@ -63,16 +62,6 @@ fn load_base_body(name: &str) -> Result { }) } -/// Expands a leading `~/` to `$HOME`; other paths pass through. -fn expand_tilde(s: &str) -> PathBuf { - if let Some(rest) = s.strip_prefix("~/") - && let Some(home) = dirs::home_dir() - { - return home.join(rest); - } - PathBuf::from(s) -} - /// Applies one override patch; errors on unknown slot name or bad color value. fn patch_slot(theme: &mut Theme, slot_name: &str, patch: &SlotPatch) -> Result<()> { let slot = slot_for_name(theme, slot_name) @@ -555,23 +544,6 @@ mod tests { }); } - // ── expand_tilde ── - - #[test] - fn expand_tilde_rewrites_leading_tilde_to_home() { - // Force a stable HOME so the assertion is deterministic. - temp_env::with_var("HOME", Some("/tmp/oxide-fake-home"), || { - let path = expand_tilde("~/themes/dark.toml"); - assert_eq!(path, PathBuf::from("/tmp/oxide-fake-home/themes/dark.toml"),); - }); - } - - #[test] - fn expand_tilde_passes_non_tilde_paths_through_unchanged() { - let path = expand_tilde("/abs/themes/dark.toml"); - assert_eq!(path, PathBuf::from("/abs/themes/dark.toml")); - } - // ── slot_for_name ── /// Every slot name must route to a unique slot. Catches the diff --git a/crates/oxide-code/src/util.rs b/crates/oxide-code/src/util.rs index e938f934..3ae98bdd 100644 --- a/crates/oxide-code/src/util.rs +++ b/crates/oxide-code/src/util.rs @@ -7,3 +7,4 @@ pub(crate) mod log; pub(crate) mod path; pub(crate) mod text; pub(crate) mod time; +pub(crate) mod tls; diff --git a/crates/oxide-code/src/util/path.rs b/crates/oxide-code/src/util/path.rs index 54419add..c670d6da 100644 --- a/crates/oxide-code/src/util/path.rs +++ b/crates/oxide-code/src/util/path.rs @@ -2,6 +2,8 @@ use std::path::{Path, PathBuf}; +use anyhow::{Result, anyhow}; + /// Resolves an XDG base directory with a `$HOME`-rooted fallback. /// /// `$XDG_*_HOME` is honoured only when absolute (the spec rejects relative values, which would @@ -29,6 +31,26 @@ pub(crate) fn tildify(path: &Path) -> String { ) } +/// Expands a leading `~` or `~/` to the user's home directory. Bare `~` resolves to `$HOME`; +/// `~/foo/bar` resolves to `$HOME/foo/bar`. Per-user forms like `~alice/...` pass through +/// unchanged (no passwd lookup) and non-tilde paths pass through as well. Errors when the input +/// starts with `~` but `dirs::home_dir()` yields no value, since a literal `~`-prefixed path +/// would otherwise fail far downstream with a misleading filesystem error. +pub(crate) fn expand_user(raw: &str) -> Result { + let Some(tail) = raw.strip_prefix('~') else { + return Ok(PathBuf::from(raw)); + }; + if !(tail.is_empty() || tail.starts_with('/')) { + return Ok(PathBuf::from(raw)); + } + let home = dirs::home_dir() + .ok_or_else(|| anyhow!("cannot expand `~` in {raw:?}: no home directory (set $HOME)"))?; + if tail.is_empty() { + return Ok(home); + } + Ok(home.join(tail.trim_start_matches('/'))) +} + #[cfg(test)] mod tests { use std::path::PathBuf; @@ -113,4 +135,62 @@ mod tests { }; assert_eq!(tildify(&home), "~/"); } + + // ── expand_user ── + + #[test] + fn expand_user_rewrites_leading_tilde_slash_to_home() { + temp_env::with_var("HOME", Some("/tmp/oxide-fake-home"), || { + assert_eq!( + expand_user("~/work/project").unwrap(), + PathBuf::from("/tmp/oxide-fake-home/work/project") + ); + }); + } + + #[test] + fn expand_user_collapses_redundant_slashes_after_tilde() { + // `~//foo` and `~///foo` should land at $HOME/foo, not at /foo. PathBuf::join replaces + // the receiver when the argument is absolute, so the tail must be trimmed first. + temp_env::with_var("HOME", Some("/tmp/oxide-fake-home"), || { + for raw in ["~//foo", "~///foo/bar"] { + let got = expand_user(raw).unwrap(); + assert!( + got.starts_with("/tmp/oxide-fake-home"), + "{raw:?} -> {got:?}", + ); + } + }); + } + + #[test] + fn expand_user_bare_tilde_resolves_to_home() { + temp_env::with_var("HOME", Some("/tmp/oxide-fake-home"), || { + assert_eq!( + expand_user("~").unwrap(), + PathBuf::from("/tmp/oxide-fake-home") + ); + }); + } + + #[test] + fn expand_user_leaves_absolute_and_relative_paths_untouched() { + for raw in ["/etc/ssl/cert.pem", "./certs/ca.pem", ""] { + assert_eq!(expand_user(raw).unwrap(), PathBuf::from(raw), "{raw:?}"); + } + } + + #[test] + fn expand_user_does_not_handle_per_user_home() { + // `~alice/foo` stays verbatim; no passwd lookup to resolve the user's home. + assert_eq!( + expand_user("~alice/foo").unwrap(), + PathBuf::from("~alice/foo") + ); + } + + // The `expand_user` home-unset error branch is unreachable from a Linux unit test: + // `dirs::home_dir()` falls back to `getpwuid_r` when `$HOME` is empty, so the `None` + // arm only fires in exotic environments (no passwd entry, Windows without profile). + // The `Result` signature is still the right contract for those cases. } diff --git a/crates/oxide-code/src/util/tls.rs b/crates/oxide-code/src/util/tls.rs new file mode 100644 index 00000000..649c6538 --- /dev/null +++ b/crates/oxide-code/src/util/tls.rs @@ -0,0 +1,156 @@ +//! Extra-trust-anchor helpers. `reqwest` with `rustls-tls` only trusts the baked-in +//! `webpki-roots` Mozilla bundle, so corporate or self-signed endpoints need explicit PEM +//! bundles appended to the client builder. + +use std::path::Path; + +use anyhow::{Context, Result}; +use reqwest::{Certificate, ClientBuilder}; + +/// Appends the PEM bundle at `path` (if any) to `builder`'s trust store. A `None` path is the +/// happy-path no-op so callers can funnel both the "extra CA configured" and the default branch +/// through one line. +pub(crate) fn apply_extra_ca_certs( + mut builder: ClientBuilder, + path: Option<&Path>, +) -> Result { + let Some(path) = path else { + return Ok(builder); + }; + for cert in load_extra_ca_certs(path)? { + builder = builder.add_root_certificate(cert); + } + Ok(builder) +} + +/// Reads a PEM-encoded bundle from disk and returns one [`Certificate`] per `BEGIN CERTIFICATE` +/// block. Empty bundles surface as an explicit error so silent misconfiguration does not +/// degrade into "still rejecting the corp CA". +pub(crate) fn load_extra_ca_certs(path: &Path) -> Result> { + let bytes = std::fs::read(path) + .with_context(|| format!("failed to read extra CA bundle at {}", path.display()))?; + let certs = Certificate::from_pem_bundle(&bytes) + .with_context(|| format!("failed to parse PEM bundle at {}", path.display()))?; + if certs.is_empty() { + anyhow::bail!( + "no PEM certificates found in {} (expected one or more `-----BEGIN CERTIFICATE-----` blocks)", + path.display(), + ); + } + Ok(certs) +} + +/// Self-signed throwaway P-256 cert generated once with `openssl req -x509 ...`. Embedding +/// keeps tests hermetic and never touches the network. 100-year validity so tests do not rot. +#[cfg(test)] +pub(crate) const TEST_CA_PEM: &str = indoc::indoc! {" + -----BEGIN CERTIFICATE----- + MIIBhzCCAS2gAwIBAgIUSHnI8j4asiQCYFCLHv+mTjaH7PIwCgYIKoZIzj0EAwIw + GDEWMBQGA1UEAwwNb3hpZGUtdGVzdC1jYTAgFw0yNjA1MTMwOTM1NTNaGA8yMTI2 + MDQxOTA5MzU1M1owGDEWMBQGA1UEAwwNb3hpZGUtdGVzdC1jYTBZMBMGByqGSM49 + AgEGCCqGSM49AwEHA0IABPPm0pogMrzkQroL61zCV3BzVH25tmWvt6c1OK5pT7Yy + tOXqTKKLiqUbpsJW6XzankZ6E8LsI9mwuzXhsQYmGE+jUzBRMB0GA1UdDgQWBBQC + hrBBOk1wizWiQQQtrpIDMACA8DAfBgNVHSMEGDAWgBQChrBBOk1wizWiQQQtrpID + MACA8DAPBgNVHRMBAf8EBTADAQH/MAoGCCqGSM49BAMCA0gAMEUCIFiHH199T6Sd + F2u46c+5D9+pdwYEd1dAgP+a21dwLwo3AiEAyn5ssAPGMPmSP8lKLRLuH+cFNEVQ + PyBNMput8iNe6eE= + -----END CERTIFICATE----- +"}; + +#[cfg(test)] +mod tests { + use std::io::Write; + + use tempfile::NamedTempFile; + + use super::*; + + fn write_pem(contents: &str) -> NamedTempFile { + let mut file = NamedTempFile::new().unwrap(); + file.write_all(contents.as_bytes()).unwrap(); + file.flush().unwrap(); + file + } + + // ── apply_extra_ca_certs ── + + #[test] + fn apply_extra_ca_certs_is_a_noop_for_none() { + // Builder must survive the no-op path; `reqwest::Client::builder().build()` confirms + // the returned builder is still valid. + let builder = reqwest::Client::builder(); + let builder = apply_extra_ca_certs(builder, None).expect("None must not error"); + builder + .build() + .expect("None path must produce a buildable client"); + } + + #[test] + fn apply_extra_ca_certs_surfaces_loader_error_with_path() { + let missing = Path::new("/definitely/does/not/exist.pem"); + let err = apply_extra_ca_certs(reqwest::Client::builder(), Some(missing)) + .expect_err("missing path must error"); + let msg = format!("{err:#}"); + assert!(msg.contains("failed to read extra CA bundle"), "{msg}"); + assert!(msg.contains("/definitely/does/not/exist.pem"), "{msg}"); + } + + #[test] + fn apply_extra_ca_certs_accepts_valid_bundle() { + let file = write_pem(TEST_CA_PEM); + let builder = reqwest::Client::builder(); + let builder = + apply_extra_ca_certs(builder, Some(file.path())).expect("valid bundle must not error"); + builder + .build() + .expect("valid bundle must produce a buildable client"); + } + + // ── load_extra_ca_certs ── + + #[test] + fn load_extra_ca_certs_parses_single_and_multi_block_bundles() { + for (label, body, expected) in [ + ("single", TEST_CA_PEM.to_owned(), 1), + ("bundle", format!("{TEST_CA_PEM}\n{TEST_CA_PEM}"), 2), + ] { + let file = write_pem(&body); + let certs = + load_extra_ca_certs(file.path()).unwrap_or_else(|e| panic!("{label}: {e:#}")); + assert_eq!(certs.len(), expected, "{label}"); + } + } + + #[test] + fn load_extra_ca_certs_rejects_empty_bundle() { + let file = write_pem("# comments only, no certificate blocks\n"); + let err = load_extra_ca_certs(file.path()).expect_err("empty bundle must error"); + let msg = format!("{err:#}"); + assert!(msg.contains("no PEM certificates found"), "{msg}"); + } + + #[test] + fn load_extra_ca_certs_reports_filename_on_read_failure() { + let missing = Path::new("/definitely/does/not/exist.pem"); + let err = load_extra_ca_certs(missing).expect_err("missing path must error"); + let msg = format!("{err:#}"); + assert!(msg.contains("failed to read extra CA bundle"), "{msg}"); + assert!(msg.contains("/definitely/does/not/exist.pem"), "{msg}"); + } + + #[test] + fn load_extra_ca_certs_reports_filename_on_parse_failure() { + let malformed = write_pem(indoc::indoc! {" + -----BEGIN CERTIFICATE----- + not base64 data + -----END CERTIFICATE----- + "}); + let err = load_extra_ca_certs(malformed.path()).expect_err("malformed PEM must error"); + let msg = format!("{err:#}"); + assert!(msg.contains("failed to parse PEM bundle"), "{msg}"); + assert!( + msg.contains(&malformed.path().display().to_string()), + "{msg}" + ); + } +} diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index 02c2c9b8..dee6487d 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -4,7 +4,7 @@ oxide-code loads configuration from multiple sources, merged in order of increas 1. **Built-in defaults.** 2. **User config file**: `~/.config/ox/config.toml` (or `$XDG_CONFIG_HOME/ox/config.toml`). -3. **Project config file**: the nearest `ox.toml` found by walking up from the current directory. Project config cannot set `client.api_key` or `client.base_url`. +3. **Project config file**: the nearest `ox.toml` found by walking up from the current directory. Project config cannot set `client.api_key`, `client.base_url`, or `client.extra_ca_certs`. 4. **Environment variables**: always win. ## Config file @@ -30,15 +30,16 @@ show_thinking = true ### `[client]`: API connection -| Key | Type | Default | Description | -| ------------------ | ------- | --------------------------- | ----------------------------------- | -| `api_key` | string | - | Anthropic API key; user config only | -| `base_url` | string | `https://api.anthropic.com` | API base URL; user config only | -| `model` | string | `claude-opus-4-7[1m]` | Model to use | -| `effort` | string | per-model (see below) | Intelligence-vs-latency tier | -| `max_tokens` | integer | effort-derived (see below) | Max tokens per response | -| `max_tool_rounds` | integer | unset (unbounded) | Per-turn safety cap on tool rounds | -| `prompt_cache_ttl` | string | `"1h"` | Prompt-cache TTL (`"5m"` or `"1h"`) | +| Key | Type | Default | Description | +| ------------------ | ------- | --------------------------- | ---------------------------------------------------------- | +| `api_key` | string | - | Anthropic API key; user config only | +| `base_url` | string | `https://api.anthropic.com` | API base URL; user config only | +| `extra_ca_certs` | string | - | PEM bundle appended to the trust store; user config only | +| `model` | string | `claude-opus-4-7[1m]` | Model to use | +| `effort` | string | per-model (see below) | Intelligence-vs-latency tier | +| `max_tokens` | integer | effort-derived (see below) | Max tokens per response | +| `max_tool_rounds` | integer | unset (unbounded) | Per-turn safety cap on tool rounds | +| `prompt_cache_ttl` | string | `"1h"` | Prompt-cache TTL (`"5m"` or `"1h"`) | #### `effort`: intelligence tier @@ -73,6 +74,18 @@ When unset, the agent loop has no per-turn round cap. Setting `max_tool_rounds = Use `base_url` only in `~/.config/ox/config.toml` or `ANTHROPIC_BASE_URL`. Project `ox.toml` cannot set it, because project files are loaded from the checkout and should not be able to redirect credentials. The URL must use HTTPS unless it points at localhost for a local proxy. +#### `extra_ca_certs`: corporate trust anchors + +oxide-code uses `rustls` with the built-in Mozilla CA bundle, so self-signed or private-CA endpoints like a corporate gateway fail with `invalid peer certificate: UnknownIssuer`. Point `extra_ca_certs` at a PEM bundle (one or more `-----BEGIN CERTIFICATE-----` blocks in one file) to append those roots to the trust store: + +```toml +[client] +base_url = "https://gw.llm.corp.example/anthropic" +extra_ca_certs = "~/.config/ox/corp-cachain.pem" +``` + +The path accepts `~/` / `~` for `$HOME`. Use an absolute or `~`-rooted path: a relative value resolves against the process working directory at read time, so it will break whenever `ox` is launched from a different folder. The field is user-config only (and rejected in project `ox.toml`) because a checked-in trust-anchor path could widen TLS trust for the process. Equivalent env var: `OX_EXTRA_CA_CERTS`. + #### `prompt_cache_ttl`: cache duration Accepted values: `"5m"` (matches the server default as of 2026-03-06) and `"1h"` (higher write premium, bigger hit-rate win on long sessions). oxide-code defaults to `"1h"` because Anthropic's silent 2026-03 TTL drop cut typical prompt-caching savings from 80 %+ to 40-55 %. See [Agentic Request Body Fields](../research/api/anthropic.md#agentic-request-body-fields) for the wire shape and cost analysis. @@ -156,6 +169,7 @@ Environment variables override all config file values. | `ANTHROPIC_MODEL` | `client.model` | `claude-opus-4-7[1m]` | Model to use | | `ANTHROPIC_EFFORT` | `client.effort` | per-model | Intelligence-vs-latency tier | | `ANTHROPIC_MAX_TOKENS` | `client.max_tokens` | effort-derived | Max tokens per response | +| `OX_EXTRA_CA_CERTS` | `client.extra_ca_certs` | - | Path to a PEM trust bundle | | `OX_MAX_TOOL_ROUNDS` | `client.max_tool_rounds` | unset (unbounded) | Per-turn tool-round cap | | `OX_PROMPT_CACHE_TTL` | `client.prompt_cache_ttl` | `1h` | Prompt-cache TTL | | `OX_COMPACTION_AUTO_ENABLED` | `client.compaction.auto_enabled` | `true` | Enable auto-compaction |