Skip to main content

lean_ctx/
proxy_setup.rs

1use std::path::Path;
2
3use crate::marked_block;
4
5const PROXY_ENV_START: &str = "# >>> lean-ctx proxy env >>>";
6const PROXY_ENV_END: &str = "# <<< lean-ctx proxy env <<<";
7
8const DEFAULT_PROXY_PORT: u16 = 4444;
9
10pub fn install_proxy_env(home: &Path, port: u16, quiet: bool) {
11    let cfg = crate::core::config::Config::load();
12    if cfg.proxy_enabled != Some(true) {
13        if !quiet {
14            println!("  Proxy env skipped (not enabled in config)");
15        }
16        return;
17    }
18    install_shell_exports(home, port, quiet);
19    install_claude_env(home, port, quiet);
20    install_codex_env(home, port, quiet);
21}
22
23/// Install proxy env without config guard (used by `lean-ctx proxy enable` which has already set the flag).
24/// `force_endpoint`: if true, overrides even non-local custom endpoints.
25pub fn install_proxy_env_unchecked(home: &Path, port: u16, quiet: bool, force_endpoint: bool) {
26    install_shell_exports(home, port, quiet);
27    if force_endpoint {
28        install_claude_env_inner(home, port, quiet, true);
29    } else {
30        install_claude_env(home, port, quiet);
31    }
32    install_codex_env(home, port, quiet);
33}
34
35pub fn preview_proxy_cleanup(home: &Path) {
36    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
37    let settings_path = settings_dir.join("settings.json");
38    if let Ok(content) = std::fs::read_to_string(&settings_path) {
39        if content.contains("ANTHROPIC_BASE_URL") {
40            let cfg = crate::core::config::Config::load();
41            if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
42                println!("  Would restore ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
43            } else {
44                println!("  Would remove ANTHROPIC_BASE_URL from Claude Code settings");
45            }
46        }
47    }
48
49    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
50    let codex_path = codex_dir.join("config.toml");
51    if let Ok(content) = std::fs::read_to_string(codex_path) {
52        if content.contains("OPENAI_BASE_URL") {
53            println!("  Would remove OPENAI_BASE_URL from Codex CLI config");
54        }
55    }
56}
57
58/// Removes stale proxy URLs from Claude Code / Codex settings when the proxy is not enabled.
59/// Returns the number of stale URLs cleaned up.
60pub fn cleanup_stale_proxy_env(home: &Path) -> usize {
61    let cfg = crate::core::config::Config::load();
62    if cfg.proxy_enabled == Some(true) {
63        return 0;
64    }
65
66    let mut cleaned = 0;
67
68    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
69    let settings_path = settings_dir.join("settings.json");
70    if let Ok(content) = std::fs::read_to_string(&settings_path) {
71        if let Ok(mut doc) = crate::core::jsonc::parse_jsonc(&content) {
72            if let Some(base_url) = doc
73                .get("env")
74                .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
75                .and_then(|v| v.as_str())
76                .map(String::from)
77            {
78                if is_local_lean_ctx_url(&base_url) {
79                    if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
80                        if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
81                            env_obj.insert(
82                                "ANTHROPIC_BASE_URL".to_string(),
83                                serde_json::Value::String(upstream.clone()),
84                            );
85                            println!(
86                                "  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings"
87                            );
88                        } else {
89                            env_obj.remove("ANTHROPIC_BASE_URL");
90                            if env_obj.is_empty() {
91                                doc.as_object_mut().map(|o| o.remove("env"));
92                            }
93                            println!(
94                                "  ✓ Removed stale ANTHROPIC_BASE_URL from Claude Code settings"
95                            );
96                        }
97                        let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
98                        let _ = std::fs::write(&settings_path, out + "\n");
99                        cleaned += 1;
100                    }
101                }
102            }
103        }
104    }
105
106    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
107    let codex_path = codex_dir.join("config.toml");
108    if let Ok(content) = std::fs::read_to_string(&codex_path) {
109        if content.contains("OPENAI_BASE_URL")
110            && (content.contains("127.0.0.1") || content.contains("localhost"))
111        {
112            let filtered: String = content
113                .lines()
114                .filter(|line| !line.trim().starts_with("OPENAI_BASE_URL"))
115                .collect::<Vec<_>>()
116                .join("\n");
117            let filtered = filtered
118                .replace("\n[env]\n\n", "\n")
119                .replace("[env]\n\n", "");
120            let filtered = if filtered.trim() == "[env]" {
121                String::new()
122            } else {
123                filtered
124            };
125            let _ = std::fs::write(&codex_path, &filtered);
126            println!("  ✓ Removed stale OPENAI_BASE_URL from Codex CLI config");
127            cleaned += 1;
128        }
129    }
130
131    cleaned
132}
133
134pub fn is_local_lean_ctx_url(url: &str) -> bool {
135    url.starts_with("http://127.0.0.1:") || url.starts_with("http://localhost:")
136}
137
138/// Returns true if Claude Code settings contain a local ANTHROPIC_BASE_URL
139/// while the proxy is not enabled (stale configuration).
140pub fn has_stale_proxy_url(home: &Path) -> bool {
141    let cfg = crate::core::config::Config::load();
142    if cfg.proxy_enabled == Some(true) {
143        return false;
144    }
145
146    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
147    let settings_path = settings_dir.join("settings.json");
148    let Ok(content) = std::fs::read_to_string(&settings_path) else {
149        return false;
150    };
151    let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
152        return false;
153    };
154
155    let base_url = doc
156        .get("env")
157        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
158        .and_then(|v| v.as_str())
159        .unwrap_or("");
160
161    is_local_lean_ctx_url(base_url)
162}
163
164pub fn uninstall_proxy_env(home: &Path, quiet: bool) {
165    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
166        let label = format!(
167            "proxy env from ~/{}",
168            rc.file_name().unwrap_or_default().to_string_lossy()
169        );
170        marked_block::remove_from_file(rc, PROXY_ENV_START, PROXY_ENV_END, quiet, &label);
171    }
172
173    let fish_config = home.join(".config/fish/config.fish");
174    if fish_config.exists() {
175        marked_block::remove_from_file(
176            &fish_config,
177            PROXY_ENV_START,
178            PROXY_ENV_END,
179            quiet,
180            "proxy env from ~/.config/fish/config.fish",
181        );
182    }
183
184    let ps_profile =
185        dirs::home_dir().map(|h| h.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"));
186    if let Some(ref ps) = ps_profile {
187        if ps.exists() {
188            marked_block::remove_from_file(
189                ps,
190                PROXY_ENV_START,
191                PROXY_ENV_END,
192                quiet,
193                "proxy env from PowerShell profile",
194            );
195        }
196    }
197
198    uninstall_claude_env(home, quiet);
199    uninstall_codex_env(home, quiet);
200}
201
202fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
203    if !is_proxy_reachable(port) {
204        if !quiet {
205            println!("  Skipping shell proxy exports (proxy not running on port {port})");
206        }
207        return;
208    }
209
210    let base = format!("http://127.0.0.1:{port}");
211
212    let posix_block = format!(
213        r#"{PROXY_ENV_START}
214export ANTHROPIC_BASE_URL="{base}"
215export OPENAI_BASE_URL="{base}"
216export GEMINI_API_BASE_URL="{base}"
217{PROXY_ENV_END}"#
218    );
219
220    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
221        if rc.exists() {
222            let label = format!(
223                "proxy env in ~/{}",
224                rc.file_name().unwrap_or_default().to_string_lossy()
225            );
226            marked_block::upsert(
227                rc,
228                PROXY_ENV_START,
229                PROXY_ENV_END,
230                &posix_block,
231                quiet,
232                &label,
233            );
234        }
235    }
236
237    let fish_config = home.join(".config/fish/config.fish");
238    if fish_config.exists() {
239        let fish_block = format!(
240            r#"{PROXY_ENV_START}
241set -gx ANTHROPIC_BASE_URL "{base}"
242set -gx OPENAI_BASE_URL "{base}"
243set -gx GEMINI_API_BASE_URL "{base}"
244{PROXY_ENV_END}"#
245        );
246        marked_block::upsert(
247            &fish_config,
248            PROXY_ENV_START,
249            PROXY_ENV_END,
250            &fish_block,
251            quiet,
252            "proxy env in ~/.config/fish/config.fish",
253        );
254    }
255
256    let ps_profile =
257        dirs::home_dir().map(|h| h.join("Documents/PowerShell/Microsoft.PowerShell_profile.ps1"));
258    if let Some(ref ps) = ps_profile {
259        if ps.exists() {
260            let ps_block = format!(
261                r#"{PROXY_ENV_START}
262$env:ANTHROPIC_BASE_URL = "{base}"
263$env:OPENAI_BASE_URL = "{base}"
264$env:GEMINI_API_BASE_URL = "{base}"
265{PROXY_ENV_END}"#
266            );
267            marked_block::upsert(
268                ps,
269                PROXY_ENV_START,
270                PROXY_ENV_END,
271                &ps_block,
272                quiet,
273                "proxy env in PowerShell profile",
274            );
275        }
276    }
277}
278
279fn uninstall_claude_env(home: &Path, quiet: bool) {
280    use crate::core::config::Config;
281
282    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
283    let settings_path = settings_dir.join("settings.json");
284    let existing = match std::fs::read_to_string(&settings_path) {
285        Ok(s) if !s.trim().is_empty() => s,
286        _ => return,
287    };
288    let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
289        Ok(v) => v,
290        Err(_) => return,
291    };
292
293    let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
294        return;
295    };
296
297    if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
298        return;
299    }
300
301    let cfg = Config::load();
302    if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
303        env_obj.insert(
304            "ANTHROPIC_BASE_URL".to_string(),
305            serde_json::Value::String(upstream.clone()),
306        );
307        if !quiet {
308            println!("  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
309        }
310    } else {
311        env_obj.remove("ANTHROPIC_BASE_URL");
312        if env_obj.is_empty() {
313            doc.as_object_mut().map(|o| o.remove("env"));
314        }
315        if !quiet {
316            println!("  ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
317        }
318    }
319
320    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
321    let _ = std::fs::write(&settings_path, content + "\n");
322}
323
324fn uninstall_codex_env(home: &Path, quiet: bool) {
325    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
326    let config_path = codex_dir.join("config.toml");
327    let existing = match std::fs::read_to_string(&config_path) {
328        Ok(s) if !s.trim().is_empty() => s,
329        _ => return,
330    };
331
332    if !existing.contains("OPENAI_BASE_URL") {
333        return;
334    }
335
336    let cleaned: String = existing
337        .lines()
338        .filter(|line| {
339            let trimmed = line.trim();
340            !trimmed.starts_with("OPENAI_BASE_URL")
341        })
342        .collect::<Vec<_>>()
343        .join("\n");
344
345    let cleaned = cleaned
346        .replace("\n[env]\n\n", "\n")
347        .replace("[env]\n\n", "");
348    let cleaned = if cleaned.trim() == "[env]" {
349        String::new()
350    } else {
351        cleaned
352    };
353
354    let _ = std::fs::write(&config_path, &cleaned);
355    if !quiet {
356        println!("  ✓ Removed OPENAI_BASE_URL from Codex CLI config");
357    }
358}
359
360fn install_claude_env(home: &Path, port: u16, quiet: bool) {
361    install_claude_env_inner(home, port, quiet, false);
362}
363
364fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
365    use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
366
367    let base = format!("http://127.0.0.1:{port}");
368
369    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
370    let settings_path = settings_dir.join("settings.json");
371    let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
372    let mut doc: serde_json::Value = if existing.trim().is_empty() {
373        serde_json::json!({})
374    } else {
375        match crate::core::jsonc::parse_jsonc(&existing) {
376            Ok(v) => v,
377            Err(_) => return,
378        }
379    };
380
381    let current_url = doc
382        .get("env")
383        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
384        .and_then(|v| v.as_str())
385        .unwrap_or("");
386
387    if current_url == base {
388        if !quiet {
389            println!("  Claude Code proxy env already configured");
390        }
391        return;
392    }
393
394    // HARD GUARD: never overwrite non-local endpoints unless --force
395    if let Some(upstream) = normalize_url_opt(current_url) {
396        if !is_local_proxy_url(&upstream) {
397            let mut cfg = Config::load();
398            if cfg.proxy.anthropic_upstream.is_none() {
399                cfg.proxy.anthropic_upstream = Some(upstream.clone());
400                let _ = cfg.save();
401            }
402
403            if !force {
404                if !quiet {
405                    eprintln!("  \u{26a0} Custom endpoint detected: {upstream}");
406                    eprintln!(
407                        "    Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
408                    );
409                }
410                return;
411            }
412            if !quiet {
413                println!("  Overriding custom endpoint (--force): {upstream}");
414            }
415        }
416    }
417
418    if !is_proxy_reachable(port) {
419        if !quiet {
420            println!("  Skipping Claude Code proxy env (proxy not running on port {port})");
421        }
422        return;
423    }
424
425    if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
426        o.entry("env")
427            .or_insert(serde_json::json!({}))
428            .as_object_mut()
429    }) {
430        env_obj.insert(
431            "ANTHROPIC_BASE_URL".to_string(),
432            serde_json::Value::String(base),
433        );
434    }
435
436    let _ = std::fs::create_dir_all(&settings_dir);
437    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
438    let _ = std::fs::write(&settings_path, content + "\n");
439    if !quiet {
440        println!("  Configured ANTHROPIC_BASE_URL in Claude Code settings");
441    }
442}
443
444/// Proxy reachability timeout. Priority: env var > config.toml > 200ms default.
445pub fn proxy_timeout() -> std::time::Duration {
446    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS") {
447        if let Ok(ms) = val.parse::<u64>() {
448            return std::time::Duration::from_millis(ms);
449        }
450    }
451    if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
452        return std::time::Duration::from_millis(ms);
453    }
454    std::time::Duration::from_millis(200)
455}
456
457fn is_proxy_reachable(port: u16) -> bool {
458    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
459    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
460    TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
461}
462
463fn install_codex_env(home: &Path, port: u16, quiet: bool) {
464    let base = format!("http://127.0.0.1:{port}");
465
466    if !is_proxy_reachable(port) {
467        if !quiet {
468            println!("  Skipping Codex CLI proxy env (proxy not running on port {port})");
469        }
470        return;
471    }
472
473    let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
474    let config_path = config_dir.join("config.toml");
475
476    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
477
478    if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
479        if !quiet {
480            println!("  Codex CLI proxy env already configured");
481        }
482        return;
483    }
484
485    if !config_dir.exists() {
486        return;
487    }
488
489    let mut content = existing;
490
491    if content.contains("[env]") {
492        if !content.contains("OPENAI_BASE_URL") {
493            content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
494        }
495    } else {
496        if !content.is_empty() && !content.ends_with('\n') {
497            content.push('\n');
498        }
499        content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
500    }
501
502    let _ = std::fs::write(&config_path, &content);
503    if !quiet {
504        println!("  Configured OPENAI_BASE_URL in Codex CLI config");
505    }
506}
507
508pub fn default_port() -> u16 {
509    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT") {
510        if let Ok(port) = val.parse::<u16>() {
511            return port;
512        }
513    }
514    let cfg = crate::core::config::Config::load();
515    if let Some(port) = cfg.proxy_port {
516        return port;
517    }
518    uid_based_port()
519}
520
521/// Derives a deterministic port from the user's UID to avoid collisions
522/// on multi-user systems. uid 1000 → 4444, uid 1001 → 4445, etc.
523/// System accounts (uid < 1000) and root always get the base port 4444.
524fn uid_based_port() -> u16 {
525    #[cfg(unix)]
526    {
527        let uid = unsafe { libc::getuid() } as u16;
528        let offset = uid.saturating_sub(1000) % 1000;
529        DEFAULT_PROXY_PORT + offset
530    }
531    #[cfg(not(unix))]
532    {
533        DEFAULT_PROXY_PORT
534    }
535}
536
537#[cfg(test)]
538mod tests {
539    use super::*;
540
541    #[test]
542    fn uid_port_first_regular_user() {
543        // uid 1000 (first regular user on most Linux) → base port
544        assert_eq!(DEFAULT_PROXY_PORT, 4444);
545    }
546
547    #[test]
548    fn uid_port_no_overflow() {
549        // Ensure port stays in valid range even with high UIDs
550        // uid 2999 → offset (2999-1000) % 1000 = 999 → port 5443
551        let port = DEFAULT_PROXY_PORT + 999;
552        assert_eq!(port, 5443);
553        assert!(port < u16::MAX);
554    }
555
556    #[test]
557    fn uid_port_system_accounts_get_base() {
558        // uid < 1000 → saturating_sub gives 0 → base port
559        let uid: u16 = 500;
560        let offset = uid.saturating_sub(1000) % 1000;
561        assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
562    }
563
564    #[test]
565    fn proxy_timeout_default_200ms() {
566        if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
567            return;
568        }
569        assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
570    }
571
572    #[test]
573    fn proxy_timeout_is_non_zero() {
574        let t = proxy_timeout();
575        assert!(t.as_millis() > 0);
576    }
577
578    #[test]
579    fn is_proxy_reachable_returns_false_on_unused_port() {
580        assert!(!is_proxy_reachable(19999));
581    }
582
583    #[test]
584    fn posix_block_contains_all_provider_env_vars() {
585        let base = "http://127.0.0.1:4444";
586        let block = format!(
587            r#"{PROXY_ENV_START}
588export ANTHROPIC_BASE_URL="{base}"
589export OPENAI_BASE_URL="{base}"
590export GEMINI_API_BASE_URL="{base}"
591{PROXY_ENV_END}"#
592        );
593        assert!(
594            block.contains("ANTHROPIC_BASE_URL"),
595            "shell exports must include ANTHROPIC_BASE_URL"
596        );
597        assert!(
598            block.contains("OPENAI_BASE_URL"),
599            "shell exports must include OPENAI_BASE_URL"
600        );
601        assert!(
602            block.contains("GEMINI_API_BASE_URL"),
603            "shell exports must include GEMINI_API_BASE_URL"
604        );
605    }
606
607    #[test]
608    fn fish_block_contains_all_provider_env_vars() {
609        let base = "http://127.0.0.1:4444";
610        let block = format!(
611            r#"{PROXY_ENV_START}
612set -gx ANTHROPIC_BASE_URL "{base}"
613set -gx OPENAI_BASE_URL "{base}"
614set -gx GEMINI_API_BASE_URL "{base}"
615{PROXY_ENV_END}"#
616        );
617        assert!(block.contains("ANTHROPIC_BASE_URL"));
618        assert!(block.contains("OPENAI_BASE_URL"));
619        assert!(block.contains("GEMINI_API_BASE_URL"));
620    }
621
622    #[test]
623    fn powershell_block_contains_all_provider_env_vars() {
624        let base = "http://127.0.0.1:4444";
625        let block = format!(
626            r#"{PROXY_ENV_START}
627$env:ANTHROPIC_BASE_URL = "{base}"
628$env:OPENAI_BASE_URL = "{base}"
629$env:GEMINI_API_BASE_URL = "{base}"
630{PROXY_ENV_END}"#
631        );
632        assert!(block.contains("ANTHROPIC_BASE_URL"));
633        assert!(block.contains("OPENAI_BASE_URL"));
634        assert!(block.contains("GEMINI_API_BASE_URL"));
635    }
636}