Skip to main content

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