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