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}