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