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}