Skip to main content

wraith_runtime/
remote.rs

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}