Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
ba20e2f
refactor(util): share ~-expansion and add PEM CA-bundle loader
hakula139 May 13, 2026
8003bce
feat(config): layer extra_ca_certs through env / user file
hakula139 May 13, 2026
bc33954
feat(client): append extra_ca_certs to Anthropic and OAuth reqwest bu…
hakula139 May 13, 2026
9fc2be6
feat(slash): surface extra_ca_certs in /config modal
hakula139 May 13, 2026
d83aca7
docs(configuration): describe extra_ca_certs and refresh crate tree
hakula139 May 13, 2026
69f3554
chore(lint): align table row width and extend cspell dictionary
hakula139 May 13, 2026
7bc4337
test(client): cover extra_ca_certs happy path in Client::new
hakula139 May 13, 2026
55f0624
test(slash): force HOME in build_modal_tildifies_extra_ca_certs_path
hakula139 May 13, 2026
ae31242
refactor(util): extract apply_extra_ca_certs helper
hakula139 May 13, 2026
8bfeb1c
fix(oauth): add build context to OAuth HTTP client builder
hakula139 May 13, 2026
e055b8d
fix(util): surface expand_user tilde-with-no-home as an error
hakula139 May 13, 2026
42e1fc5
test(oauth): cover extra_ca_certs in refresh_oauth_token
hakula139 May 13, 2026
52f041f
docs(configuration): call out working-dir pitfall for relative extra_…
hakula139 May 13, 2026
ba1de3f
test(client): tighten extra_ca_certs test comments and rename
hakula139 May 13, 2026
575d147
docs(configuration): drop antithesis in extra_ca_certs path guidance
hakula139 May 13, 2026
40b286f
style: sweep extra_ca_certs doc / comment nits
hakula139 May 13, 2026
ed9778f
test: close extra_ca_certs coverage gaps and split a bundled test
hakula139 May 13, 2026
0f1ab39
test: reorder Config::load and config::file test sections to mirror p…
hakula139 May 13, 2026
dbee26d
test(util): regenerate TEST_CA_PEM with 100-year validity
hakula139 May 13, 2026
7f76b3b
test(client): phrase extra-CA trust test as scenario not mechanism
hakula139 May 13, 2026
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
3 changes: 2 additions & 1 deletion .cspell/words.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ anthropics
anyhow
APFS
atuin
cachain
catppuccin
Catppuccin
claudemd
Clawd
clippy
Expand Down Expand Up @@ -87,6 +87,7 @@ Tera
thiserror
throbber
tildified
tildifies
tildify
TOCTOU
tokio
Expand Down
5 changes: 3 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
37 changes: 32 additions & 5 deletions crates/oxide-code/src/client/anthropic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand All @@ -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]
Expand Down
1 change: 1 addition & 0 deletions crates/oxide-code/src/client/anthropic/testing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub(crate) fn test_config(base_url: impl Into<String>, auth: Auth, model: &str)
Config {
auth,
base_url: base_url.into(),
extra_ca_certs: None,
model: model.to_owned(),
effort: None,
max_tokens: 128,
Expand Down
188 changes: 141 additions & 47 deletions crates/oxide-code/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,15 @@ pub(crate) mod file;
mod oauth;

use std::fmt;
use std::path::PathBuf;
use std::str::FromStr;

use anyhow::{Context, Result, bail};
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";
Expand Down Expand Up @@ -51,6 +53,8 @@ pub(crate) struct ConfigSnapshot {
pub(crate) effort: Option<Effort>,
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<PathBuf>,
pub(crate) max_tokens: u32,
/// `None` means the agent loop runs without a per-turn round cap.
pub(crate) max_tool_rounds: Option<u32>,
Expand Down Expand Up @@ -271,6 +275,9 @@ pub(crate) struct Config {
pub(crate) effort: Option<Effort>,
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<PathBuf>,
pub(crate) max_tokens: u32,
/// `None` means the agent loop runs without a per-turn round cap.
pub(crate) max_tool_rounds: Option<u32>,
Expand All @@ -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)",
Expand Down Expand Up @@ -378,6 +393,7 @@ impl Config {
effort,
auth,
base_url,
extra_ca_certs,
max_tokens,
max_tool_rounds,
prompt_cache_ttl,
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Loading