remote.rs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  1. use std::collections::BTreeMap;
  2. use std::env;
  3. use std::fs;
  4. use std::io;
  5. use std::path::{Path, PathBuf};
  6. pub const DEFAULT_REMOTE_BASE_URL: &str = "https://api.anthropic.com";
  7. pub const DEFAULT_SESSION_TOKEN_PATH: &str = "/run/ccr/session_token";
  8. pub const DEFAULT_SYSTEM_CA_BUNDLE: &str = "/etc/ssl/certs/ca-certificates.crt";
  9. pub const UPSTREAM_PROXY_ENV_KEYS: [&str; 8] = [
  10. "HTTPS_PROXY",
  11. "https_proxy",
  12. "NO_PROXY",
  13. "no_proxy",
  14. "SSL_CERT_FILE",
  15. "NODE_EXTRA_CA_CERTS",
  16. "REQUESTS_CA_BUNDLE",
  17. "CURL_CA_BUNDLE",
  18. ];
  19. pub const NO_PROXY_HOSTS: [&str; 16] = [
  20. "localhost",
  21. "127.0.0.1",
  22. "::1",
  23. "169.254.0.0/16",
  24. "10.0.0.0/8",
  25. "172.16.0.0/12",
  26. "192.168.0.0/16",
  27. "anthropic.com",
  28. ".anthropic.com",
  29. "*.anthropic.com",
  30. "github.com",
  31. "api.github.com",
  32. "*.github.com",
  33. "*.githubusercontent.com",
  34. "registry.npmjs.org",
  35. "index.crates.io",
  36. ];
  37. #[derive(Debug, Clone, PartialEq, Eq)]
  38. pub struct RemoteSessionContext {
  39. pub enabled: bool,
  40. pub session_id: Option<String>,
  41. pub base_url: String,
  42. }
  43. #[derive(Debug, Clone, PartialEq, Eq)]
  44. pub struct UpstreamProxyBootstrap {
  45. pub remote: RemoteSessionContext,
  46. pub upstream_proxy_enabled: bool,
  47. pub token_path: PathBuf,
  48. pub ca_bundle_path: PathBuf,
  49. pub system_ca_path: PathBuf,
  50. pub token: Option<String>,
  51. }
  52. #[derive(Debug, Clone, PartialEq, Eq)]
  53. pub struct UpstreamProxyState {
  54. pub enabled: bool,
  55. pub proxy_url: Option<String>,
  56. pub ca_bundle_path: Option<PathBuf>,
  57. pub no_proxy: String,
  58. }
  59. impl RemoteSessionContext {
  60. #[must_use]
  61. pub fn from_env() -> Self {
  62. Self::from_env_map(&env::vars().collect())
  63. }
  64. #[must_use]
  65. pub fn from_env_map(env_map: &BTreeMap<String, String>) -> Self {
  66. Self {
  67. enabled: env_truthy(env_map.get("CLAUDE_CODE_REMOTE")),
  68. session_id: env_map
  69. .get("CLAUDE_CODE_REMOTE_SESSION_ID")
  70. .filter(|value| !value.is_empty())
  71. .cloned(),
  72. base_url: env_map
  73. .get("ANTHROPIC_BASE_URL")
  74. .filter(|value| !value.is_empty())
  75. .cloned()
  76. .unwrap_or_else(|| DEFAULT_REMOTE_BASE_URL.to_string()),
  77. }
  78. }
  79. }
  80. impl UpstreamProxyBootstrap {
  81. #[must_use]
  82. pub fn from_env() -> Self {
  83. Self::from_env_map(&env::vars().collect())
  84. }
  85. #[must_use]
  86. pub fn from_env_map(env_map: &BTreeMap<String, String>) -> Self {
  87. let remote = RemoteSessionContext::from_env_map(env_map);
  88. let token_path = env_map
  89. .get("CCR_SESSION_TOKEN_PATH")
  90. .filter(|value| !value.is_empty())
  91. .map_or_else(|| PathBuf::from(DEFAULT_SESSION_TOKEN_PATH), PathBuf::from);
  92. let system_ca_path = env_map
  93. .get("CCR_SYSTEM_CA_BUNDLE")
  94. .filter(|value| !value.is_empty())
  95. .map_or_else(|| PathBuf::from(DEFAULT_SYSTEM_CA_BUNDLE), PathBuf::from);
  96. let ca_bundle_path = env_map
  97. .get("CCR_CA_BUNDLE_PATH")
  98. .filter(|value| !value.is_empty())
  99. .map_or_else(default_ca_bundle_path, PathBuf::from);
  100. let token = read_token(&token_path).ok().flatten();
  101. Self {
  102. remote,
  103. upstream_proxy_enabled: env_truthy(env_map.get("CCR_UPSTREAM_PROXY_ENABLED")),
  104. token_path,
  105. ca_bundle_path,
  106. system_ca_path,
  107. token,
  108. }
  109. }
  110. #[must_use]
  111. pub fn should_enable(&self) -> bool {
  112. self.remote.enabled
  113. && self.upstream_proxy_enabled
  114. && self.remote.session_id.is_some()
  115. && self.token.is_some()
  116. }
  117. #[must_use]
  118. pub fn ws_url(&self) -> String {
  119. upstream_proxy_ws_url(&self.remote.base_url)
  120. }
  121. #[must_use]
  122. pub fn state_for_port(&self, port: u16) -> UpstreamProxyState {
  123. if !self.should_enable() {
  124. return UpstreamProxyState::disabled();
  125. }
  126. UpstreamProxyState {
  127. enabled: true,
  128. proxy_url: Some(format!("http://127.0.0.1:{port}")),
  129. ca_bundle_path: Some(self.ca_bundle_path.clone()),
  130. no_proxy: no_proxy_list(),
  131. }
  132. }
  133. }
  134. impl UpstreamProxyState {
  135. #[must_use]
  136. pub fn disabled() -> Self {
  137. Self {
  138. enabled: false,
  139. proxy_url: None,
  140. ca_bundle_path: None,
  141. no_proxy: no_proxy_list(),
  142. }
  143. }
  144. #[must_use]
  145. pub fn subprocess_env(&self) -> BTreeMap<String, String> {
  146. if !self.enabled {
  147. return BTreeMap::new();
  148. }
  149. let Some(proxy_url) = &self.proxy_url else {
  150. return BTreeMap::new();
  151. };
  152. let Some(ca_bundle_path) = &self.ca_bundle_path else {
  153. return BTreeMap::new();
  154. };
  155. let ca_bundle_path = ca_bundle_path.to_string_lossy().into_owned();
  156. BTreeMap::from([
  157. ("HTTPS_PROXY".to_string(), proxy_url.clone()),
  158. ("https_proxy".to_string(), proxy_url.clone()),
  159. ("NO_PROXY".to_string(), self.no_proxy.clone()),
  160. ("no_proxy".to_string(), self.no_proxy.clone()),
  161. ("SSL_CERT_FILE".to_string(), ca_bundle_path.clone()),
  162. ("NODE_EXTRA_CA_CERTS".to_string(), ca_bundle_path.clone()),
  163. ("REQUESTS_CA_BUNDLE".to_string(), ca_bundle_path.clone()),
  164. ("CURL_CA_BUNDLE".to_string(), ca_bundle_path),
  165. ])
  166. }
  167. }
  168. pub fn read_token(path: &Path) -> io::Result<Option<String>> {
  169. match fs::read_to_string(path) {
  170. Ok(contents) => {
  171. let token = contents.trim();
  172. if token.is_empty() {
  173. Ok(None)
  174. } else {
  175. Ok(Some(token.to_string()))
  176. }
  177. }
  178. Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(None),
  179. Err(error) => Err(error),
  180. }
  181. }
  182. #[must_use]
  183. pub fn upstream_proxy_ws_url(base_url: &str) -> String {
  184. let base = base_url.trim_end_matches('/');
  185. let ws_base = if let Some(stripped) = base.strip_prefix("https://") {
  186. format!("wss://{stripped}")
  187. } else if let Some(stripped) = base.strip_prefix("http://") {
  188. format!("ws://{stripped}")
  189. } else {
  190. format!("wss://{base}")
  191. };
  192. format!("{ws_base}/v1/code/upstreamproxy/ws")
  193. }
  194. #[must_use]
  195. pub fn no_proxy_list() -> String {
  196. let mut hosts = NO_PROXY_HOSTS.to_vec();
  197. hosts.extend(["pypi.org", "files.pythonhosted.org", "proxy.golang.org"]);
  198. hosts.join(",")
  199. }
  200. #[must_use]
  201. pub fn inherited_upstream_proxy_env(
  202. env_map: &BTreeMap<String, String>,
  203. ) -> BTreeMap<String, String> {
  204. if !(env_map.contains_key("HTTPS_PROXY") && env_map.contains_key("SSL_CERT_FILE")) {
  205. return BTreeMap::new();
  206. }
  207. UPSTREAM_PROXY_ENV_KEYS
  208. .iter()
  209. .filter_map(|key| {
  210. env_map
  211. .get(*key)
  212. .map(|value| ((*key).to_string(), value.clone()))
  213. })
  214. .collect()
  215. }
  216. fn default_ca_bundle_path() -> PathBuf {
  217. env::var_os("HOME")
  218. .map_or_else(|| PathBuf::from("."), PathBuf::from)
  219. .join(".ccr")
  220. .join("ca-bundle.crt")
  221. }
  222. fn env_truthy(value: Option<&String>) -> bool {
  223. value.is_some_and(|raw| {
  224. matches!(
  225. raw.trim().to_ascii_lowercase().as_str(),
  226. "1" | "true" | "yes" | "on"
  227. )
  228. })
  229. }
  230. #[cfg(test)]
  231. mod tests {
  232. use super::{
  233. inherited_upstream_proxy_env, no_proxy_list, read_token, upstream_proxy_ws_url,
  234. RemoteSessionContext, UpstreamProxyBootstrap,
  235. };
  236. use std::collections::BTreeMap;
  237. use std::fs;
  238. use std::path::PathBuf;
  239. use std::time::{SystemTime, UNIX_EPOCH};
  240. fn temp_dir() -> PathBuf {
  241. let nanos = SystemTime::now()
  242. .duration_since(UNIX_EPOCH)
  243. .expect("time should be after epoch")
  244. .as_nanos();
  245. std::env::temp_dir().join(format!("runtime-remote-{nanos}"))
  246. }
  247. #[test]
  248. fn remote_context_reads_env_state() {
  249. let env = BTreeMap::from([
  250. ("CLAUDE_CODE_REMOTE".to_string(), "true".to_string()),
  251. (
  252. "CLAUDE_CODE_REMOTE_SESSION_ID".to_string(),
  253. "session-123".to_string(),
  254. ),
  255. (
  256. "ANTHROPIC_BASE_URL".to_string(),
  257. "https://remote.test".to_string(),
  258. ),
  259. ]);
  260. let context = RemoteSessionContext::from_env_map(&env);
  261. assert!(context.enabled);
  262. assert_eq!(context.session_id.as_deref(), Some("session-123"));
  263. assert_eq!(context.base_url, "https://remote.test");
  264. }
  265. #[test]
  266. fn bootstrap_fails_open_when_token_or_session_is_missing() {
  267. let env = BTreeMap::from([
  268. ("CLAUDE_CODE_REMOTE".to_string(), "1".to_string()),
  269. ("CCR_UPSTREAM_PROXY_ENABLED".to_string(), "true".to_string()),
  270. ]);
  271. let bootstrap = UpstreamProxyBootstrap::from_env_map(&env);
  272. assert!(!bootstrap.should_enable());
  273. assert!(!bootstrap.state_for_port(8080).enabled);
  274. }
  275. #[test]
  276. fn bootstrap_derives_proxy_state_and_env() {
  277. let root = temp_dir();
  278. let token_path = root.join("session_token");
  279. fs::create_dir_all(&root).expect("temp dir");
  280. fs::write(&token_path, "secret-token\n").expect("write token");
  281. let env = BTreeMap::from([
  282. ("CLAUDE_CODE_REMOTE".to_string(), "1".to_string()),
  283. ("CCR_UPSTREAM_PROXY_ENABLED".to_string(), "true".to_string()),
  284. (
  285. "CLAUDE_CODE_REMOTE_SESSION_ID".to_string(),
  286. "session-123".to_string(),
  287. ),
  288. (
  289. "ANTHROPIC_BASE_URL".to_string(),
  290. "https://remote.test".to_string(),
  291. ),
  292. (
  293. "CCR_SESSION_TOKEN_PATH".to_string(),
  294. token_path.to_string_lossy().into_owned(),
  295. ),
  296. (
  297. "CCR_CA_BUNDLE_PATH".to_string(),
  298. root.join("ca-bundle.crt").to_string_lossy().into_owned(),
  299. ),
  300. ]);
  301. let bootstrap = UpstreamProxyBootstrap::from_env_map(&env);
  302. assert!(bootstrap.should_enable());
  303. assert_eq!(bootstrap.token.as_deref(), Some("secret-token"));
  304. assert_eq!(
  305. bootstrap.ws_url(),
  306. "wss://remote.test/v1/code/upstreamproxy/ws"
  307. );
  308. let state = bootstrap.state_for_port(9443);
  309. assert!(state.enabled);
  310. let env = state.subprocess_env();
  311. assert_eq!(
  312. env.get("HTTPS_PROXY").map(String::as_str),
  313. Some("http://127.0.0.1:9443")
  314. );
  315. assert_eq!(
  316. env.get("SSL_CERT_FILE").map(String::as_str),
  317. Some(root.join("ca-bundle.crt").to_string_lossy().as_ref())
  318. );
  319. fs::remove_dir_all(root).expect("cleanup temp dir");
  320. }
  321. #[test]
  322. fn token_reader_trims_and_handles_missing_files() {
  323. let root = temp_dir();
  324. fs::create_dir_all(&root).expect("temp dir");
  325. let token_path = root.join("session_token");
  326. fs::write(&token_path, " abc123 \n").expect("write token");
  327. assert_eq!(
  328. read_token(&token_path).expect("read token").as_deref(),
  329. Some("abc123")
  330. );
  331. assert_eq!(
  332. read_token(&root.join("missing")).expect("missing token"),
  333. None
  334. );
  335. fs::remove_dir_all(root).expect("cleanup temp dir");
  336. }
  337. #[test]
  338. fn inherited_proxy_env_requires_proxy_and_ca() {
  339. let env = BTreeMap::from([
  340. (
  341. "HTTPS_PROXY".to_string(),
  342. "http://127.0.0.1:8888".to_string(),
  343. ),
  344. (
  345. "SSL_CERT_FILE".to_string(),
  346. "/tmp/ca-bundle.crt".to_string(),
  347. ),
  348. ("NO_PROXY".to_string(), "localhost".to_string()),
  349. ]);
  350. let inherited = inherited_upstream_proxy_env(&env);
  351. assert_eq!(inherited.len(), 3);
  352. assert_eq!(
  353. inherited.get("NO_PROXY").map(String::as_str),
  354. Some("localhost")
  355. );
  356. assert!(inherited_upstream_proxy_env(&BTreeMap::new()).is_empty());
  357. }
  358. #[test]
  359. fn helper_outputs_match_expected_shapes() {
  360. assert_eq!(
  361. upstream_proxy_ws_url("http://localhost:3000/"),
  362. "ws://localhost:3000/v1/code/upstreamproxy/ws"
  363. );
  364. assert!(no_proxy_list().contains("anthropic.com"));
  365. assert!(no_proxy_list().contains("github.com"));
  366. }
  367. }