diff --git a/rust/crates/runtime/src/lib.rs b/rust/crates/runtime/src/lib.rs index 4381166..9feb763 100644 --- a/rust/crates/runtime/src/lib.rs +++ b/rust/crates/runtime/src/lib.rs @@ -8,6 +8,7 @@ mod json; mod oauth; mod permissions; mod prompt; +mod remote; mod session; mod usage; @@ -45,5 +46,10 @@ pub use prompt::{ load_system_prompt, prepend_bullets, ContextFile, ProjectContext, PromptBuildError, SystemPromptBuilder, FRONTIER_MODEL_NAME, SYSTEM_PROMPT_DYNAMIC_BOUNDARY, }; +pub use remote::{ + inherited_upstream_proxy_env, no_proxy_list, read_token, upstream_proxy_ws_url, + RemoteSessionContext, UpstreamProxyBootstrap, UpstreamProxyState, DEFAULT_REMOTE_BASE_URL, + DEFAULT_SESSION_TOKEN_PATH, DEFAULT_SYSTEM_CA_BUNDLE, NO_PROXY_HOSTS, UPSTREAM_PROXY_ENV_KEYS, +}; pub use session::{ContentBlock, ConversationMessage, MessageRole, Session, SessionError}; pub use usage::{TokenUsage, UsageTracker}; diff --git a/rust/crates/runtime/src/remote.rs b/rust/crates/runtime/src/remote.rs new file mode 100644 index 0000000..24ee780 --- /dev/null +++ b/rust/crates/runtime/src/remote.rs @@ -0,0 +1,401 @@ +use std::collections::BTreeMap; +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; + +pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.anthropic.com"; +pub const DEFAULT_SESSION_TOKEN_PATH: &str = "/run/ccr/session_token"; +pub const DEFAULT_SYSTEM_CA_BUNDLE: &str = "/etc/ssl/certs/ca-certificates.crt"; + +pub const UPSTREAM_PROXY_ENV_KEYS: [&str; 8] = [ + "HTTPS_PROXY", + "https_proxy", + "NO_PROXY", + "no_proxy", + "SSL_CERT_FILE", + "NODE_EXTRA_CA_CERTS", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", +]; + +pub const NO_PROXY_HOSTS: [&str; 16] = [ + "localhost", + "127.0.0.1", + "::1", + "169.254.0.0/16", + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + "anthropic.com", + ".anthropic.com", + "*.anthropic.com", + "github.com", + "api.github.com", + "*.github.com", + "*.githubusercontent.com", + "registry.npmjs.org", + "index.crates.io", +]; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RemoteSessionContext { + pub enabled: bool, + pub session_id: Option, + pub base_url: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamProxyBootstrap { + pub remote: RemoteSessionContext, + pub upstream_proxy_enabled: bool, + pub token_path: PathBuf, + pub ca_bundle_path: PathBuf, + pub system_ca_path: PathBuf, + pub token: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamProxyState { + pub enabled: bool, + pub proxy_url: Option, + pub ca_bundle_path: Option, + pub no_proxy: String, +} + +impl RemoteSessionContext { + #[must_use] + pub fn from_env() -> Self { + Self::from_env_map(&env::vars().collect()) + } + + #[must_use] + pub fn from_env_map(env_map: &BTreeMap) -> Self { + Self { + enabled: env_truthy(env_map.get("CLAUDE_CODE_REMOTE")), + session_id: env_map + .get("CLAUDE_CODE_REMOTE_SESSION_ID") + .filter(|value| !value.is_empty()) + .cloned(), + base_url: env_map + .get("ANTHROPIC_BASE_URL") + .filter(|value| !value.is_empty()) + .cloned() + .unwrap_or_else(|| DEFAULT_REMOTE_BASE_URL.to_string()), + } + } +} + +impl UpstreamProxyBootstrap { + #[must_use] + pub fn from_env() -> Self { + Self::from_env_map(&env::vars().collect()) + } + + #[must_use] + pub fn from_env_map(env_map: &BTreeMap) -> Self { + let remote = RemoteSessionContext::from_env_map(env_map); + let token_path = env_map + .get("CCR_SESSION_TOKEN_PATH") + .filter(|value| !value.is_empty()) + .map_or_else(|| PathBuf::from(DEFAULT_SESSION_TOKEN_PATH), PathBuf::from); + let system_ca_path = env_map + .get("CCR_SYSTEM_CA_BUNDLE") + .filter(|value| !value.is_empty()) + .map_or_else(|| PathBuf::from(DEFAULT_SYSTEM_CA_BUNDLE), PathBuf::from); + let ca_bundle_path = env_map + .get("CCR_CA_BUNDLE_PATH") + .filter(|value| !value.is_empty()) + .map_or_else(default_ca_bundle_path, PathBuf::from); + let token = read_token(&token_path).ok().flatten(); + + Self { + remote, + upstream_proxy_enabled: env_truthy(env_map.get("CCR_UPSTREAM_PROXY_ENABLED")), + token_path, + ca_bundle_path, + system_ca_path, + token, + } + } + + #[must_use] + pub fn should_enable(&self) -> bool { + self.remote.enabled + && self.upstream_proxy_enabled + && self.remote.session_id.is_some() + && self.token.is_some() + } + + #[must_use] + pub fn ws_url(&self) -> String { + upstream_proxy_ws_url(&self.remote.base_url) + } + + #[must_use] + pub fn state_for_port(&self, port: u16) -> UpstreamProxyState { + if !self.should_enable() { + return UpstreamProxyState::disabled(); + } + UpstreamProxyState { + enabled: true, + proxy_url: Some(format!("http://127.0.0.1:{port}")), + ca_bundle_path: Some(self.ca_bundle_path.clone()), + no_proxy: no_proxy_list(), + } + } +} + +impl UpstreamProxyState { + #[must_use] + pub fn disabled() -> Self { + Self { + enabled: false, + proxy_url: None, + ca_bundle_path: None, + no_proxy: no_proxy_list(), + } + } + + #[must_use] + pub fn subprocess_env(&self) -> BTreeMap { + if !self.enabled { + return BTreeMap::new(); + } + let Some(proxy_url) = &self.proxy_url else { + return BTreeMap::new(); + }; + let Some(ca_bundle_path) = &self.ca_bundle_path else { + return BTreeMap::new(); + }; + let ca_bundle_path = ca_bundle_path.to_string_lossy().into_owned(); + BTreeMap::from([ + ("HTTPS_PROXY".to_string(), proxy_url.clone()), + ("https_proxy".to_string(), proxy_url.clone()), + ("NO_PROXY".to_string(), self.no_proxy.clone()), + ("no_proxy".to_string(), self.no_proxy.clone()), + ("SSL_CERT_FILE".to_string(), ca_bundle_path.clone()), + ("NODE_EXTRA_CA_CERTS".to_string(), ca_bundle_path.clone()), + ("REQUESTS_CA_BUNDLE".to_string(), ca_bundle_path.clone()), + ("CURL_CA_BUNDLE".to_string(), ca_bundle_path), + ]) + } +} + +pub fn read_token(path: &Path) -> io::Result> { + match fs::read_to_string(path) { + Ok(contents) => { + let token = contents.trim(); + if token.is_empty() { + Ok(None) + } else { + Ok(Some(token.to_string())) + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error), + } +} + +#[must_use] +pub fn upstream_proxy_ws_url(base_url: &str) -> String { + let base = base_url.trim_end_matches('/'); + let ws_base = if let Some(stripped) = base.strip_prefix("https://") { + format!("wss://{stripped}") + } else if let Some(stripped) = base.strip_prefix("http://") { + format!("ws://{stripped}") + } else { + format!("wss://{base}") + }; + format!("{ws_base}/v1/code/upstreamproxy/ws") +} + +#[must_use] +pub fn no_proxy_list() -> String { + let mut hosts = NO_PROXY_HOSTS.to_vec(); + hosts.extend(["pypi.org", "files.pythonhosted.org", "proxy.golang.org"]); + hosts.join(",") +} + +#[must_use] +pub fn inherited_upstream_proxy_env( + env_map: &BTreeMap, +) -> BTreeMap { + if !(env_map.contains_key("HTTPS_PROXY") && env_map.contains_key("SSL_CERT_FILE")) { + return BTreeMap::new(); + } + UPSTREAM_PROXY_ENV_KEYS + .iter() + .filter_map(|key| { + env_map + .get(*key) + .map(|value| ((*key).to_string(), value.clone())) + }) + .collect() +} + +fn default_ca_bundle_path() -> PathBuf { + env::var_os("HOME") + .map_or_else(|| PathBuf::from("."), PathBuf::from) + .join(".ccr") + .join("ca-bundle.crt") +} + +fn env_truthy(value: Option<&String>) -> bool { + value.is_some_and(|raw| { + matches!( + raw.trim().to_ascii_lowercase().as_str(), + "1" | "true" | "yes" | "on" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::{ + inherited_upstream_proxy_env, no_proxy_list, read_token, upstream_proxy_ws_url, + RemoteSessionContext, UpstreamProxyBootstrap, + }; + use std::collections::BTreeMap; + use std::fs; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + fn temp_dir() -> PathBuf { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time should be after epoch") + .as_nanos(); + std::env::temp_dir().join(format!("runtime-remote-{nanos}")) + } + + #[test] + fn remote_context_reads_env_state() { + let env = BTreeMap::from([ + ("CLAUDE_CODE_REMOTE".to_string(), "true".to_string()), + ( + "CLAUDE_CODE_REMOTE_SESSION_ID".to_string(), + "session-123".to_string(), + ), + ( + "ANTHROPIC_BASE_URL".to_string(), + "https://remote.test".to_string(), + ), + ]); + let context = RemoteSessionContext::from_env_map(&env); + assert!(context.enabled); + assert_eq!(context.session_id.as_deref(), Some("session-123")); + assert_eq!(context.base_url, "https://remote.test"); + } + + #[test] + fn bootstrap_fails_open_when_token_or_session_is_missing() { + let env = BTreeMap::from([ + ("CLAUDE_CODE_REMOTE".to_string(), "1".to_string()), + ("CCR_UPSTREAM_PROXY_ENABLED".to_string(), "true".to_string()), + ]); + let bootstrap = UpstreamProxyBootstrap::from_env_map(&env); + assert!(!bootstrap.should_enable()); + assert!(!bootstrap.state_for_port(8080).enabled); + } + + #[test] + fn bootstrap_derives_proxy_state_and_env() { + let root = temp_dir(); + let token_path = root.join("session_token"); + fs::create_dir_all(&root).expect("temp dir"); + fs::write(&token_path, "secret-token\n").expect("write token"); + + let env = BTreeMap::from([ + ("CLAUDE_CODE_REMOTE".to_string(), "1".to_string()), + ("CCR_UPSTREAM_PROXY_ENABLED".to_string(), "true".to_string()), + ( + "CLAUDE_CODE_REMOTE_SESSION_ID".to_string(), + "session-123".to_string(), + ), + ( + "ANTHROPIC_BASE_URL".to_string(), + "https://remote.test".to_string(), + ), + ( + "CCR_SESSION_TOKEN_PATH".to_string(), + token_path.to_string_lossy().into_owned(), + ), + ( + "CCR_CA_BUNDLE_PATH".to_string(), + root.join("ca-bundle.crt").to_string_lossy().into_owned(), + ), + ]); + + let bootstrap = UpstreamProxyBootstrap::from_env_map(&env); + assert!(bootstrap.should_enable()); + assert_eq!(bootstrap.token.as_deref(), Some("secret-token")); + assert_eq!( + bootstrap.ws_url(), + "wss://remote.test/v1/code/upstreamproxy/ws" + ); + + let state = bootstrap.state_for_port(9443); + assert!(state.enabled); + let env = state.subprocess_env(); + assert_eq!( + env.get("HTTPS_PROXY").map(String::as_str), + Some("http://127.0.0.1:9443") + ); + assert_eq!( + env.get("SSL_CERT_FILE").map(String::as_str), + Some(root.join("ca-bundle.crt").to_string_lossy().as_ref()) + ); + + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn token_reader_trims_and_handles_missing_files() { + let root = temp_dir(); + fs::create_dir_all(&root).expect("temp dir"); + let token_path = root.join("session_token"); + fs::write(&token_path, " abc123 \n").expect("write token"); + assert_eq!( + read_token(&token_path).expect("read token").as_deref(), + Some("abc123") + ); + assert_eq!( + read_token(&root.join("missing")).expect("missing token"), + None + ); + fs::remove_dir_all(root).expect("cleanup temp dir"); + } + + #[test] + fn inherited_proxy_env_requires_proxy_and_ca() { + let env = BTreeMap::from([ + ( + "HTTPS_PROXY".to_string(), + "http://127.0.0.1:8888".to_string(), + ), + ( + "SSL_CERT_FILE".to_string(), + "/tmp/ca-bundle.crt".to_string(), + ), + ("NO_PROXY".to_string(), "localhost".to_string()), + ]); + let inherited = inherited_upstream_proxy_env(&env); + assert_eq!(inherited.len(), 3); + assert_eq!( + inherited.get("NO_PROXY").map(String::as_str), + Some("localhost") + ); + assert!(inherited_upstream_proxy_env(&BTreeMap::new()).is_empty()); + } + + #[test] + fn helper_outputs_match_expected_shapes() { + assert_eq!( + upstream_proxy_ws_url("http://localhost:3000/"), + "ws://localhost:3000/v1/code/upstreamproxy/ws" + ); + assert!(no_proxy_list().contains("anthropic.com")); + assert!(no_proxy_list().contains("github.com")); + } +}