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 = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
185    if let Some(ref ps) = ps_profile {
186        if ps.exists() {
187            marked_block::remove_from_file(
188                ps,
189                PROXY_ENV_START,
190                PROXY_ENV_END,
191                quiet,
192                "proxy env from PowerShell profile",
193            );
194        }
195    }
196
197    uninstall_claude_env(home, quiet);
198    uninstall_codex_env(home, quiet);
199}
200
201fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
202    if !is_proxy_reachable(port) {
203        if !quiet {
204            println!("  Skipping shell proxy exports (proxy not running on port {port})");
205        }
206        return;
207    }
208
209    let base = format!("http://127.0.0.1:{port}");
210
211    let posix_block = format!(
212        r#"{PROXY_ENV_START}
213export ANTHROPIC_BASE_URL="{base}"
214export OPENAI_BASE_URL="{base}"
215export GEMINI_API_BASE_URL="{base}"
216{PROXY_ENV_END}"#
217    );
218
219    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
220        if rc.exists() {
221            let label = format!(
222                "proxy env in ~/{}",
223                rc.file_name().unwrap_or_default().to_string_lossy()
224            );
225            marked_block::upsert(
226                rc,
227                PROXY_ENV_START,
228                PROXY_ENV_END,
229                &posix_block,
230                quiet,
231                &label,
232            );
233        }
234    }
235
236    let fish_config = home.join(".config/fish/config.fish");
237    if fish_config.exists() {
238        let fish_block = format!(
239            r#"{PROXY_ENV_START}
240set -gx ANTHROPIC_BASE_URL "{base}"
241set -gx OPENAI_BASE_URL "{base}"
242set -gx GEMINI_API_BASE_URL "{base}"
243{PROXY_ENV_END}"#
244        );
245        marked_block::upsert(
246            &fish_config,
247            PROXY_ENV_START,
248            PROXY_ENV_END,
249            &fish_block,
250            quiet,
251            "proxy env in ~/.config/fish/config.fish",
252        );
253    }
254
255    let ps_profile = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
256    if let Some(ref ps) = ps_profile {
257        if ps.exists() {
258            let ps_block = format!(
259                r#"{PROXY_ENV_START}
260$env:ANTHROPIC_BASE_URL = "{base}"
261$env:OPENAI_BASE_URL = "{base}"
262$env:GEMINI_API_BASE_URL = "{base}"
263{PROXY_ENV_END}"#
264            );
265            marked_block::upsert(
266                ps,
267                PROXY_ENV_START,
268                PROXY_ENV_END,
269                &ps_block,
270                quiet,
271                "proxy env in PowerShell profile",
272            );
273        }
274    }
275}
276
277fn uninstall_claude_env(home: &Path, quiet: bool) {
278    use crate::core::config::Config;
279
280    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
281    let settings_path = settings_dir.join("settings.json");
282    let existing = match std::fs::read_to_string(&settings_path) {
283        Ok(s) if !s.trim().is_empty() => s,
284        _ => return,
285    };
286    let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
287        Ok(v) => v,
288        Err(_) => return,
289    };
290
291    let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
292        return;
293    };
294
295    if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
296        return;
297    }
298
299    let cfg = Config::load();
300    if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
301        env_obj.insert(
302            "ANTHROPIC_BASE_URL".to_string(),
303            serde_json::Value::String(upstream.clone()),
304        );
305        if !quiet {
306            println!("  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
307        }
308    } else {
309        env_obj.remove("ANTHROPIC_BASE_URL");
310        if env_obj.is_empty() {
311            doc.as_object_mut().map(|o| o.remove("env"));
312        }
313        if !quiet {
314            println!("  ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
315        }
316    }
317
318    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
319    let _ = std::fs::write(&settings_path, content + "\n");
320}
321
322fn uninstall_codex_env(home: &Path, quiet: bool) {
323    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
324    let config_path = codex_dir.join("config.toml");
325    let existing = match std::fs::read_to_string(&config_path) {
326        Ok(s) if !s.trim().is_empty() => s,
327        _ => return,
328    };
329
330    if !existing.contains("OPENAI_BASE_URL") {
331        return;
332    }
333
334    let cleaned: String = existing
335        .lines()
336        .filter(|line| {
337            let trimmed = line.trim();
338            !trimmed.starts_with("OPENAI_BASE_URL")
339        })
340        .collect::<Vec<_>>()
341        .join("\n");
342
343    let cleaned = cleaned
344        .replace("\n[env]\n\n", "\n")
345        .replace("[env]\n\n", "");
346    let cleaned = if cleaned.trim() == "[env]" {
347        String::new()
348    } else {
349        cleaned
350    };
351
352    let _ = std::fs::write(&config_path, &cleaned);
353    if !quiet {
354        println!("  ✓ Removed OPENAI_BASE_URL from Codex CLI config");
355    }
356}
357
358fn install_claude_env(home: &Path, port: u16, quiet: bool) {
359    install_claude_env_inner(home, port, quiet, false);
360}
361
362fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
363    use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
364
365    let base = format!("http://127.0.0.1:{port}");
366
367    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
368    let settings_path = settings_dir.join("settings.json");
369    let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
370    let mut doc: serde_json::Value = if existing.trim().is_empty() {
371        serde_json::json!({})
372    } else {
373        match crate::core::jsonc::parse_jsonc(&existing) {
374            Ok(v) => v,
375            Err(_) => return,
376        }
377    };
378
379    let current_url = doc
380        .get("env")
381        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
382        .and_then(|v| v.as_str())
383        .unwrap_or("");
384
385    if current_url == base {
386        if !quiet {
387            println!("  Claude Code proxy env already configured");
388        }
389        return;
390    }
391
392    // HARD GUARD: never overwrite non-local endpoints unless --force
393    if let Some(upstream) = normalize_url_opt(current_url) {
394        if !is_local_proxy_url(&upstream) {
395            let mut cfg = Config::load();
396            if cfg.proxy.anthropic_upstream.is_none() {
397                cfg.proxy.anthropic_upstream = Some(upstream.clone());
398                let _ = cfg.save();
399            }
400
401            if !force {
402                if !quiet {
403                    eprintln!("  \u{26a0} Custom endpoint detected: {upstream}");
404                    eprintln!(
405                        "    Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
406                    );
407                }
408                return;
409            }
410            if !quiet {
411                println!("  Overriding custom endpoint (--force): {upstream}");
412            }
413        }
414    }
415
416    if !is_proxy_reachable(port) {
417        if !quiet {
418            println!("  Skipping Claude Code proxy env (proxy not running on port {port})");
419        }
420        return;
421    }
422
423    if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
424        o.entry("env")
425            .or_insert(serde_json::json!({}))
426            .as_object_mut()
427    }) {
428        env_obj.insert(
429            "ANTHROPIC_BASE_URL".to_string(),
430            serde_json::Value::String(base),
431        );
432    }
433
434    let _ = std::fs::create_dir_all(&settings_dir);
435    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
436    let _ = std::fs::write(&settings_path, content + "\n");
437    if !quiet {
438        println!("  Configured ANTHROPIC_BASE_URL in Claude Code settings");
439    }
440}
441
442/// Proxy reachability timeout. Priority: env var > config.toml > 200ms default.
443pub fn proxy_timeout() -> std::time::Duration {
444    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS") {
445        if let Ok(ms) = val.parse::<u64>() {
446            return std::time::Duration::from_millis(ms);
447        }
448    }
449    if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
450        return std::time::Duration::from_millis(ms);
451    }
452    std::time::Duration::from_millis(200)
453}
454
455fn is_proxy_reachable(port: u16) -> bool {
456    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
457    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
458    TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
459}
460
461fn install_codex_env(home: &Path, port: u16, quiet: bool) {
462    let base = format!("http://127.0.0.1:{port}");
463
464    if !is_proxy_reachable(port) {
465        if !quiet {
466            println!("  Skipping Codex CLI proxy env (proxy not running on port {port})");
467        }
468        return;
469    }
470
471    let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
472    let config_path = config_dir.join("config.toml");
473
474    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
475
476    if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
477        if !quiet {
478            println!("  Codex CLI proxy env already configured");
479        }
480        return;
481    }
482
483    if !config_dir.exists() {
484        return;
485    }
486
487    let mut content = existing;
488
489    if content.contains("[env]") {
490        if !content.contains("OPENAI_BASE_URL") {
491            content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
492        }
493    } else {
494        if !content.is_empty() && !content.ends_with('\n') {
495            content.push('\n');
496        }
497        content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
498    }
499
500    let _ = std::fs::write(&config_path, &content);
501    if !quiet {
502        println!("  Configured OPENAI_BASE_URL in Codex CLI config");
503    }
504}
505
506pub fn default_port() -> u16 {
507    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT") {
508        if let Ok(port) = val.parse::<u16>() {
509            return port;
510        }
511    }
512    let cfg = crate::core::config::Config::load();
513    if let Some(port) = cfg.proxy_port {
514        return port;
515    }
516    uid_based_port()
517}
518
519/// Derives a deterministic port from the user's UID to avoid collisions
520/// on multi-user systems. uid 1000 → 4444, uid 1001 → 4445, etc.
521/// System accounts (uid < 1000) and root always get the base port 4444.
522fn uid_based_port() -> u16 {
523    #[cfg(unix)]
524    {
525        // SAFETY: `getuid` takes no arguments, always succeeds, and only reads
526        // the calling process's real UID — no preconditions, no UB.
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}