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