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
10/// Comment written in place of the `ANTHROPIC_BASE_URL` export when no Anthropic API
11/// key is detectable. A Claude Pro/Max subscription authenticates via OAuth against
12/// `api.anthropic.com` directly and is rejected by any custom base URL, so we must not
13/// route it through the proxy.
14const ANTHROPIC_OMITTED_NOTE: &str =
15    "ANTHROPIC_BASE_URL omitted: Claude Pro/Max subscription authenticates against api.anthropic.com directly (set ANTHROPIC_API_KEY to route Claude through the proxy)";
16
17pub fn install_proxy_env(home: &Path, port: u16, quiet: bool) {
18    let cfg = crate::core::config::Config::load();
19    if cfg.proxy_enabled != Some(true) {
20        if !quiet {
21            println!("  Proxy env skipped (not enabled in config)");
22        }
23        return;
24    }
25    install_shell_exports(home, port, quiet);
26    install_claude_env(home, port, quiet);
27    install_codex_env(home, port, quiet);
28}
29
30/// Install proxy env without config guard (used by `lean-ctx proxy enable` which has already set the flag).
31/// `force_endpoint`: if true, overrides even non-local custom endpoints.
32pub fn install_proxy_env_unchecked(home: &Path, port: u16, quiet: bool, force_endpoint: bool) {
33    install_shell_exports(home, port, quiet);
34    if force_endpoint {
35        install_claude_env_inner(home, port, quiet, true);
36    } else {
37        install_claude_env(home, port, quiet);
38    }
39    install_codex_env(home, port, quiet);
40}
41
42pub fn preview_proxy_cleanup(home: &Path) {
43    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
44    let settings_path = settings_dir.join("settings.json");
45    if let Ok(content) = std::fs::read_to_string(&settings_path) {
46        if content.contains("ANTHROPIC_BASE_URL") {
47            let cfg = crate::core::config::Config::load();
48            if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
49                println!("  Would restore ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
50            } else {
51                println!("  Would remove ANTHROPIC_BASE_URL from Claude Code settings");
52            }
53        }
54    }
55
56    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
57    let codex_path = codex_dir.join("config.toml");
58    if let Ok(content) = std::fs::read_to_string(codex_path) {
59        if content.contains("OPENAI_BASE_URL") {
60            println!("  Would remove OPENAI_BASE_URL from Codex CLI config");
61        }
62    }
63}
64
65/// Removes stale proxy URLs from Claude Code / Codex settings when the proxy is not enabled.
66/// Returns the number of stale URLs cleaned up.
67pub fn cleanup_stale_proxy_env(home: &Path) -> usize {
68    let cfg = crate::core::config::Config::load();
69    if cfg.proxy_enabled == Some(true) {
70        return 0;
71    }
72
73    let mut cleaned = 0;
74
75    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
76    let settings_path = settings_dir.join("settings.json");
77    if let Ok(content) = std::fs::read_to_string(&settings_path) {
78        if let Ok(mut doc) = crate::core::jsonc::parse_jsonc(&content) {
79            if let Some(base_url) = doc
80                .get("env")
81                .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
82                .and_then(|v| v.as_str())
83                .map(String::from)
84            {
85                if is_local_lean_ctx_url(&base_url) {
86                    if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
87                        if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
88                            env_obj.insert(
89                                "ANTHROPIC_BASE_URL".to_string(),
90                                serde_json::Value::String(upstream.clone()),
91                            );
92                            println!(
93                                "  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings"
94                            );
95                        } else {
96                            env_obj.remove("ANTHROPIC_BASE_URL");
97                            if env_obj.is_empty() {
98                                doc.as_object_mut().map(|o| o.remove("env"));
99                            }
100                            println!(
101                                "  ✓ Removed stale ANTHROPIC_BASE_URL from Claude Code settings"
102                            );
103                        }
104                        let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
105                        let _ = std::fs::write(&settings_path, out + "\n");
106                        cleaned += 1;
107                    }
108                }
109            }
110        }
111    }
112
113    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
114    let codex_path = codex_dir.join("config.toml");
115    if let Ok(content) = std::fs::read_to_string(&codex_path) {
116        if content.contains("OPENAI_BASE_URL")
117            && (content.contains("127.0.0.1") || content.contains("localhost"))
118        {
119            let filtered: String = content
120                .lines()
121                .filter(|line| !line.trim().starts_with("OPENAI_BASE_URL"))
122                .collect::<Vec<_>>()
123                .join("\n");
124            let filtered = filtered
125                .replace("\n[env]\n\n", "\n")
126                .replace("[env]\n\n", "");
127            let filtered = if filtered.trim() == "[env]" {
128                String::new()
129            } else {
130                filtered
131            };
132            let _ = std::fs::write(&codex_path, &filtered);
133            println!("  ✓ Removed stale OPENAI_BASE_URL from Codex CLI config");
134            cleaned += 1;
135        }
136    }
137
138    cleaned
139}
140
141pub fn is_local_lean_ctx_url(url: &str) -> bool {
142    url.starts_with("http://127.0.0.1:") || url.starts_with("http://localhost:")
143}
144
145/// Returns true if Claude Code settings contain a local ANTHROPIC_BASE_URL
146/// while the proxy is not enabled (stale configuration).
147pub fn has_stale_proxy_url(home: &Path) -> bool {
148    let cfg = crate::core::config::Config::load();
149    if cfg.proxy_enabled == Some(true) {
150        return false;
151    }
152
153    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
154    let settings_path = settings_dir.join("settings.json");
155    let Ok(content) = std::fs::read_to_string(&settings_path) else {
156        return false;
157    };
158    let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
159        return false;
160    };
161
162    let base_url = doc
163        .get("env")
164        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
165        .and_then(|v| v.as_str())
166        .unwrap_or("");
167
168    is_local_lean_ctx_url(base_url)
169}
170
171/// Returns true when an Anthropic **API key** is available for the proxy to forward
172/// upstream.
173///
174/// The proxy never injects credentials (see `proxy/forward.rs` — only
175/// `ALLOWED_REQUEST_HEADERS` are forwarded), so it can only help Claude Code when the
176/// user runs in API-key (pay-as-you-go) mode. A Claude **Pro/Max subscription**
177/// authenticates via OAuth directly against `api.anthropic.com`; that token is rejected
178/// by any custom `ANTHROPIC_BASE_URL`, so redirecting subscription traffic through the
179/// proxy only breaks auth (login loop / 401). When this returns `false`, callers must
180/// NOT point Claude Code at the proxy.
181pub fn anthropic_api_key_available(home: &Path) -> bool {
182    // 1) Process environment — covers shells and Claude Code launched from them.
183    for var in ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] {
184        if std::env::var(var).is_ok_and(|v| !v.trim().is_empty()) {
185            return true;
186        }
187    }
188
189    // 2) Claude Code settings.json — an explicit key, an auth token, or a dynamic
190    //    key helper all indicate API-key mode.
191    let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
192    let Ok(content) = std::fs::read_to_string(&settings_path) else {
193        return false;
194    };
195    let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
196        return false;
197    };
198
199    if doc
200        .get("apiKeyHelper")
201        .and_then(|v| v.as_str())
202        .is_some_and(|v| !v.trim().is_empty())
203    {
204        return true;
205    }
206
207    let env = doc.get("env");
208    ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]
209        .iter()
210        .any(|key| {
211            env.and_then(|e| e.get(*key))
212                .and_then(|v| v.as_str())
213                .is_some_and(|v| !v.trim().is_empty())
214        })
215}
216
217/// Explains why Claude Code was left pointing at `api.anthropic.com` instead of the
218/// proxy: a Pro/Max subscription (OAuth) cannot authenticate through a custom base URL.
219fn warn_claude_subscription_skip() {
220    eprintln!("  \u{26a0} Claude Code: no ANTHROPIC_API_KEY detected (Pro/Max subscription?).");
221    eprintln!("    The proxy forwards your credential upstream but never injects one, and a");
222    eprintln!("    subscription token only authenticates against api.anthropic.com directly.");
223    eprintln!("    Leaving ANTHROPIC_BASE_URL untouched so Claude Code keeps working.");
224    eprintln!("    Savings on a subscription: use the lean-ctx MCP tools (ctx_read /");
225    eprintln!("    ctx_search / ctx_shell). Pay-as-you-go? Set ANTHROPIC_API_KEY, then run:");
226    eprintln!("      lean-ctx proxy enable");
227}
228
229pub fn uninstall_proxy_env(home: &Path, quiet: bool) {
230    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
231        let label = format!(
232            "proxy env from ~/{}",
233            rc.file_name().unwrap_or_default().to_string_lossy()
234        );
235        marked_block::remove_from_file(rc, PROXY_ENV_START, PROXY_ENV_END, quiet, &label);
236    }
237
238    let fish_config = home.join(".config/fish/config.fish");
239    if fish_config.exists() {
240        marked_block::remove_from_file(
241            &fish_config,
242            PROXY_ENV_START,
243            PROXY_ENV_END,
244            quiet,
245            "proxy env from ~/.config/fish/config.fish",
246        );
247    }
248
249    let ps_profile = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
250    if let Some(ref ps) = ps_profile {
251        if ps.exists() {
252            marked_block::remove_from_file(
253                ps,
254                PROXY_ENV_START,
255                PROXY_ENV_END,
256                quiet,
257                "proxy env from PowerShell profile",
258            );
259        }
260    }
261
262    uninstall_claude_env(home, quiet);
263    uninstall_codex_env(home, quiet);
264}
265
266fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
267    if !is_proxy_reachable(port) {
268        if !quiet {
269            println!("  Skipping shell proxy exports (proxy not running on port {port})");
270        }
271        return;
272    }
273
274    let base = format!("http://127.0.0.1:{port}");
275
276    // Only route Claude through the proxy when an API key is available; a Pro/Max
277    // subscription must keep talking to api.anthropic.com directly (see
278    // `anthropic_api_key_available`).
279    let include_anthropic = anthropic_api_key_available(home);
280
281    let posix_anthropic = if include_anthropic {
282        format!(r#"export ANTHROPIC_BASE_URL="{base}""#)
283    } else {
284        format!("# {ANTHROPIC_OMITTED_NOTE}")
285    };
286    let posix_block = format!(
287        r#"{PROXY_ENV_START}
288{posix_anthropic}
289export OPENAI_BASE_URL="{base}"
290export GEMINI_API_BASE_URL="{base}"
291{PROXY_ENV_END}"#
292    );
293
294    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
295        if rc.exists() {
296            let label = format!(
297                "proxy env in ~/{}",
298                rc.file_name().unwrap_or_default().to_string_lossy()
299            );
300            marked_block::upsert(
301                rc,
302                PROXY_ENV_START,
303                PROXY_ENV_END,
304                &posix_block,
305                quiet,
306                &label,
307            );
308        }
309    }
310
311    let fish_config = home.join(".config/fish/config.fish");
312    if fish_config.exists() {
313        let fish_anthropic = if include_anthropic {
314            format!(r#"set -gx ANTHROPIC_BASE_URL "{base}""#)
315        } else {
316            format!("# {ANTHROPIC_OMITTED_NOTE}")
317        };
318        let fish_block = format!(
319            r#"{PROXY_ENV_START}
320{fish_anthropic}
321set -gx OPENAI_BASE_URL "{base}"
322set -gx GEMINI_API_BASE_URL "{base}"
323{PROXY_ENV_END}"#
324        );
325        marked_block::upsert(
326            &fish_config,
327            PROXY_ENV_START,
328            PROXY_ENV_END,
329            &fish_block,
330            quiet,
331            "proxy env in ~/.config/fish/config.fish",
332        );
333    }
334
335    let ps_profile = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
336    if let Some(ref ps) = ps_profile {
337        if ps.exists() {
338            let ps_anthropic = if include_anthropic {
339                format!(r#"$env:ANTHROPIC_BASE_URL = "{base}""#)
340            } else {
341                format!("# {ANTHROPIC_OMITTED_NOTE}")
342            };
343            let ps_block = format!(
344                r#"{PROXY_ENV_START}
345{ps_anthropic}
346$env:OPENAI_BASE_URL = "{base}"
347$env:GEMINI_API_BASE_URL = "{base}"
348{PROXY_ENV_END}"#
349            );
350            marked_block::upsert(
351                ps,
352                PROXY_ENV_START,
353                PROXY_ENV_END,
354                &ps_block,
355                quiet,
356                "proxy env in PowerShell profile",
357            );
358        }
359    }
360}
361
362fn uninstall_claude_env(home: &Path, quiet: bool) {
363    use crate::core::config::Config;
364
365    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
366    let settings_path = settings_dir.join("settings.json");
367    let existing = match std::fs::read_to_string(&settings_path) {
368        Ok(s) if !s.trim().is_empty() => s,
369        _ => return,
370    };
371    let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
372        Ok(v) => v,
373        Err(_) => return,
374    };
375
376    let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
377        return;
378    };
379
380    if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
381        return;
382    }
383
384    let cfg = Config::load();
385    if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
386        env_obj.insert(
387            "ANTHROPIC_BASE_URL".to_string(),
388            serde_json::Value::String(upstream.clone()),
389        );
390        if !quiet {
391            println!("  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
392        }
393    } else {
394        env_obj.remove("ANTHROPIC_BASE_URL");
395        if env_obj.is_empty() {
396            doc.as_object_mut().map(|o| o.remove("env"));
397        }
398        if !quiet {
399            println!("  ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
400        }
401    }
402
403    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
404    let _ = std::fs::write(&settings_path, content + "\n");
405}
406
407fn uninstall_codex_env(home: &Path, quiet: bool) {
408    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
409    let config_path = codex_dir.join("config.toml");
410    let existing = match std::fs::read_to_string(&config_path) {
411        Ok(s) if !s.trim().is_empty() => s,
412        _ => return,
413    };
414
415    if !existing.contains("OPENAI_BASE_URL") {
416        return;
417    }
418
419    let cleaned: String = existing
420        .lines()
421        .filter(|line| {
422            let trimmed = line.trim();
423            !trimmed.starts_with("OPENAI_BASE_URL")
424        })
425        .collect::<Vec<_>>()
426        .join("\n");
427
428    let cleaned = cleaned
429        .replace("\n[env]\n\n", "\n")
430        .replace("[env]\n\n", "");
431    let cleaned = if cleaned.trim() == "[env]" {
432        String::new()
433    } else {
434        cleaned
435    };
436
437    let _ = std::fs::write(&config_path, &cleaned);
438    if !quiet {
439        println!("  ✓ Removed OPENAI_BASE_URL from Codex CLI config");
440    }
441}
442
443fn install_claude_env(home: &Path, port: u16, quiet: bool) {
444    install_claude_env_inner(home, port, quiet, false);
445}
446
447fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
448    use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
449
450    let base = format!("http://127.0.0.1:{port}");
451
452    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
453    let settings_path = settings_dir.join("settings.json");
454    let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
455    let mut doc: serde_json::Value = if existing.trim().is_empty() {
456        serde_json::json!({})
457    } else {
458        match crate::core::jsonc::parse_jsonc(&existing) {
459            Ok(v) => v,
460            Err(_) => return,
461        }
462    };
463
464    let current_url = doc
465        .get("env")
466        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
467        .and_then(|v| v.as_str())
468        .unwrap_or("")
469        .to_string();
470
471    // SUBSCRIPTION GUARD: the proxy never injects credentials, so redirecting Claude
472    // Code only works in API-key mode. A Claude Pro/Max subscription (OAuth) is rejected
473    // by a custom ANTHROPIC_BASE_URL → login loop / 401. When no API key is detectable we
474    // must not point Claude Code at the proxy. `--force` overrides for power users whose
475    // key lives somewhere we cannot probe (e.g. a keychain or apiKeyHelper we missed).
476    if !force && !anthropic_api_key_available(home) {
477        // Repair an existing stale local redirect so Claude Code reaches Anthropic again.
478        if is_local_lean_ctx_url(&current_url) {
479            let cfg = Config::load();
480            if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
481                if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
482                    env_obj.insert(
483                        "ANTHROPIC_BASE_URL".to_string(),
484                        serde_json::Value::String(upstream.clone()),
485                    );
486                } else {
487                    env_obj.remove("ANTHROPIC_BASE_URL");
488                    if env_obj.is_empty() {
489                        doc.as_object_mut().map(|o| o.remove("env"));
490                    }
491                }
492                let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
493                let _ = std::fs::write(&settings_path, out + "\n");
494            }
495        }
496        if !quiet {
497            warn_claude_subscription_skip();
498        }
499        return;
500    }
501
502    if current_url == base {
503        if !quiet {
504            println!("  Claude Code proxy env already configured");
505        }
506        return;
507    }
508
509    // HARD GUARD: never overwrite non-local endpoints unless --force
510    if let Some(upstream) = normalize_url_opt(&current_url) {
511        if !is_local_proxy_url(&upstream) {
512            let mut cfg = Config::load();
513            if cfg.proxy.anthropic_upstream.is_none() {
514                cfg.proxy.anthropic_upstream = Some(upstream.clone());
515                let _ = cfg.save();
516            }
517
518            if !force {
519                if !quiet {
520                    eprintln!("  \u{26a0} Custom endpoint detected: {upstream}");
521                    eprintln!(
522                        "    Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
523                    );
524                }
525                return;
526            }
527            if !quiet {
528                println!("  Overriding custom endpoint (--force): {upstream}");
529            }
530        }
531    }
532
533    if !is_proxy_reachable(port) {
534        if !quiet {
535            println!("  Skipping Claude Code proxy env (proxy not running on port {port})");
536        }
537        return;
538    }
539
540    if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
541        o.entry("env")
542            .or_insert(serde_json::json!({}))
543            .as_object_mut()
544    }) {
545        env_obj.insert(
546            "ANTHROPIC_BASE_URL".to_string(),
547            serde_json::Value::String(base),
548        );
549    }
550
551    let _ = std::fs::create_dir_all(&settings_dir);
552    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
553    let _ = std::fs::write(&settings_path, content + "\n");
554    if !quiet {
555        println!("  Configured ANTHROPIC_BASE_URL in Claude Code settings");
556    }
557}
558
559/// Proxy reachability timeout. Priority: env var > config.toml > 200ms default.
560pub fn proxy_timeout() -> std::time::Duration {
561    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS") {
562        if let Ok(ms) = val.parse::<u64>() {
563            return std::time::Duration::from_millis(ms);
564        }
565    }
566    if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
567        return std::time::Duration::from_millis(ms);
568    }
569    std::time::Duration::from_millis(200)
570}
571
572fn is_proxy_reachable(port: u16) -> bool {
573    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
574    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
575    TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
576}
577
578fn install_codex_env(home: &Path, port: u16, quiet: bool) {
579    let base = format!("http://127.0.0.1:{port}");
580
581    if !is_proxy_reachable(port) {
582        if !quiet {
583            println!("  Skipping Codex CLI proxy env (proxy not running on port {port})");
584        }
585        return;
586    }
587
588    let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
589    let config_path = config_dir.join("config.toml");
590
591    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
592
593    if existing.contains("OPENAI_BASE_URL") && existing.contains(&base) {
594        if !quiet {
595            println!("  Codex CLI proxy env already configured");
596        }
597        return;
598    }
599
600    if !config_dir.exists() {
601        return;
602    }
603
604    let mut content = existing;
605
606    if content.contains("[env]") {
607        if !content.contains("OPENAI_BASE_URL") {
608            content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{base}\""));
609        }
610    } else {
611        if !content.is_empty() && !content.ends_with('\n') {
612            content.push('\n');
613        }
614        content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{base}\"\n"));
615    }
616
617    let _ = std::fs::write(&config_path, &content);
618    if !quiet {
619        println!("  Configured OPENAI_BASE_URL in Codex CLI config");
620    }
621}
622
623pub fn default_port() -> u16 {
624    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT") {
625        if let Ok(port) = val.parse::<u16>() {
626            return port;
627        }
628    }
629    let cfg = crate::core::config::Config::load();
630    if let Some(port) = cfg.proxy_port {
631        return port;
632    }
633    uid_based_port()
634}
635
636/// Derives a deterministic port from the user's UID to avoid collisions
637/// on multi-user systems. uid 1000 → 4444, uid 1001 → 4445, etc.
638/// System accounts (uid < 1000) and root always get the base port 4444.
639fn uid_based_port() -> u16 {
640    #[cfg(unix)]
641    {
642        // SAFETY: `getuid` takes no arguments, always succeeds, and only reads
643        // the calling process's real UID — no preconditions, no UB.
644        let uid = unsafe { libc::getuid() } as u16;
645        let offset = uid.saturating_sub(1000) % 1000;
646        DEFAULT_PROXY_PORT + offset
647    }
648    #[cfg(not(unix))]
649    {
650        DEFAULT_PROXY_PORT
651    }
652}
653
654#[cfg(test)]
655mod tests {
656    use super::*;
657
658    #[test]
659    fn uid_port_first_regular_user() {
660        // uid 1000 (first regular user on most Linux) → base port
661        assert_eq!(DEFAULT_PROXY_PORT, 4444);
662    }
663
664    #[test]
665    fn uid_port_no_overflow() {
666        // Ensure port stays in valid range even with high UIDs
667        // uid 2999 → offset (2999-1000) % 1000 = 999 → port 5443
668        let port = DEFAULT_PROXY_PORT + 999;
669        assert_eq!(port, 5443);
670        assert!(port < u16::MAX);
671    }
672
673    #[test]
674    fn uid_port_system_accounts_get_base() {
675        // uid < 1000 → saturating_sub gives 0 → base port
676        let uid: u16 = 500;
677        let offset = uid.saturating_sub(1000) % 1000;
678        assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
679    }
680
681    #[test]
682    fn proxy_timeout_default_200ms() {
683        if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
684            return;
685        }
686        assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
687    }
688
689    #[test]
690    fn proxy_timeout_is_non_zero() {
691        let t = proxy_timeout();
692        assert!(t.as_millis() > 0);
693    }
694
695    #[test]
696    fn is_proxy_reachable_returns_false_on_unused_port() {
697        assert!(!is_proxy_reachable(19999));
698    }
699
700    #[test]
701    fn posix_block_contains_all_provider_env_vars() {
702        let base = "http://127.0.0.1:4444";
703        let block = format!(
704            r#"{PROXY_ENV_START}
705export ANTHROPIC_BASE_URL="{base}"
706export OPENAI_BASE_URL="{base}"
707export GEMINI_API_BASE_URL="{base}"
708{PROXY_ENV_END}"#
709        );
710        assert!(
711            block.contains("ANTHROPIC_BASE_URL"),
712            "shell exports must include ANTHROPIC_BASE_URL"
713        );
714        assert!(
715            block.contains("OPENAI_BASE_URL"),
716            "shell exports must include OPENAI_BASE_URL"
717        );
718        assert!(
719            block.contains("GEMINI_API_BASE_URL"),
720            "shell exports must include GEMINI_API_BASE_URL"
721        );
722    }
723
724    #[test]
725    fn fish_block_contains_all_provider_env_vars() {
726        let base = "http://127.0.0.1:4444";
727        let block = format!(
728            r#"{PROXY_ENV_START}
729set -gx ANTHROPIC_BASE_URL "{base}"
730set -gx OPENAI_BASE_URL "{base}"
731set -gx GEMINI_API_BASE_URL "{base}"
732{PROXY_ENV_END}"#
733        );
734        assert!(block.contains("ANTHROPIC_BASE_URL"));
735        assert!(block.contains("OPENAI_BASE_URL"));
736        assert!(block.contains("GEMINI_API_BASE_URL"));
737    }
738
739    #[test]
740    fn powershell_block_contains_all_provider_env_vars() {
741        let base = "http://127.0.0.1:4444";
742        let block = format!(
743            r#"{PROXY_ENV_START}
744$env:ANTHROPIC_BASE_URL = "{base}"
745$env:OPENAI_BASE_URL = "{base}"
746$env:GEMINI_API_BASE_URL = "{base}"
747{PROXY_ENV_END}"#
748        );
749        assert!(block.contains("ANTHROPIC_BASE_URL"));
750        assert!(block.contains("OPENAI_BASE_URL"));
751        assert!(block.contains("GEMINI_API_BASE_URL"));
752    }
753
754    /// The subscription guard reads the process environment; these tests are only
755    /// meaningful when the test runner itself does not provide an Anthropic key.
756    fn env_provides_anthropic_key() -> bool {
757        std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty())
758            || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok_and(|v| !v.trim().is_empty())
759    }
760
761    /// `claude_state_dir` honours `CLAUDE_CONFIG_DIR`; when set it would escape the
762    /// temp HOME and read the real settings file, so skip in that case.
763    fn claude_dir_overridden() -> bool {
764        std::env::var("CLAUDE_CONFIG_DIR").is_ok_and(|v| !v.trim().is_empty())
765    }
766
767    fn write_claude_settings(home: &Path, json: &str) -> std::path::PathBuf {
768        let dir = home.join(".claude");
769        std::fs::create_dir_all(&dir).unwrap();
770        let path = dir.join("settings.json");
771        std::fs::write(&path, json).unwrap();
772        path
773    }
774
775    #[test]
776    fn api_key_available_true_with_api_key_helper() {
777        if claude_dir_overridden() {
778            return;
779        }
780        let home = tempfile::tempdir().unwrap();
781        write_claude_settings(home.path(), r#"{"apiKeyHelper": "echo sk-test"}"#);
782        assert!(anthropic_api_key_available(home.path()));
783    }
784
785    #[test]
786    fn api_key_available_true_with_settings_env_key() {
787        if claude_dir_overridden() {
788            return;
789        }
790        let home = tempfile::tempdir().unwrap();
791        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
792        assert!(anthropic_api_key_available(home.path()));
793    }
794
795    #[test]
796    fn api_key_available_false_without_key() {
797        if env_provides_anthropic_key() || claude_dir_overridden() {
798            return;
799        }
800        let home = tempfile::tempdir().unwrap();
801        write_claude_settings(home.path(), r#"{"env": {}}"#);
802        assert!(!anthropic_api_key_available(home.path()));
803    }
804
805    #[test]
806    fn api_key_available_false_when_no_settings_file() {
807        if env_provides_anthropic_key() || claude_dir_overridden() {
808            return;
809        }
810        let home = tempfile::tempdir().unwrap();
811        assert!(!anthropic_api_key_available(home.path()));
812    }
813
814    #[test]
815    fn subscription_guard_skips_redirect_without_key() {
816        if env_provides_anthropic_key() || claude_dir_overridden() {
817            return;
818        }
819        let home = tempfile::tempdir().unwrap();
820        // No settings file → subscription mode, empty current URL → nothing to repair.
821        install_claude_env_inner(home.path(), 4444, true, false);
822        let settings = home.path().join(".claude/settings.json");
823        assert!(
824            !settings.exists(),
825            "subscription mode must not write a proxy redirect"
826        );
827    }
828
829    #[test]
830    fn subscription_guard_repairs_stale_local_redirect() {
831        if env_provides_anthropic_key() || claude_dir_overridden() {
832            return;
833        }
834        let home = tempfile::tempdir().unwrap();
835        let path = write_claude_settings(
836            home.path(),
837            r#"{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4444"}}"#,
838        );
839        install_claude_env_inner(home.path(), 4444, true, false);
840        let after = std::fs::read_to_string(&path).unwrap();
841        let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
842        let base = doc
843            .get("env")
844            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
845            .and_then(|v| v.as_str())
846            .unwrap_or("");
847        assert!(
848            !is_local_lean_ctx_url(base),
849            "stale local redirect must be repaired in subscription mode, got {base:?}"
850        );
851    }
852
853    /// API-key mode must STILL route Claude through the proxy (we only protect
854    /// subscriptions; pay-as-you-go users keep their compression). Uses a real bound
855    /// port so `is_proxy_reachable` passes, exercising the full production path.
856    #[test]
857    fn install_redirects_claude_when_api_key_present() {
858        if claude_dir_overridden() {
859            return;
860        }
861        let home = tempfile::tempdir().unwrap();
862        // API-key mode declared in settings.json → deterministic regardless of env.
863        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
864        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
865        let port = listener.local_addr().unwrap().port();
866
867        install_claude_env_inner(home.path(), port, true, false);
868
869        let after = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
870        let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
871        let base = doc
872            .get("env")
873            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
874            .and_then(|v| v.as_str())
875            .unwrap_or("");
876        assert_eq!(
877            base,
878            format!("http://127.0.0.1:{port}"),
879            "API-key mode must route Claude through the proxy"
880        );
881    }
882
883    /// Shell export: subscription mode keeps OpenAI/Gemini but omits the ANTHROPIC line
884    /// (replaced by an explanatory comment), so a shell-launched Claude stays on
885    /// api.anthropic.com.
886    #[test]
887    fn shell_export_omits_anthropic_without_key() {
888        if env_provides_anthropic_key() || claude_dir_overridden() {
889            return;
890        }
891        let home = tempfile::tempdir().unwrap();
892        std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
893        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
894        let port = listener.local_addr().unwrap().port();
895
896        install_shell_exports(home.path(), port, true);
897
898        let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
899        assert!(rc.contains("OPENAI_BASE_URL"), "OpenAI export must remain");
900        assert!(
901            rc.contains("GEMINI_API_BASE_URL"),
902            "Gemini export must remain"
903        );
904        assert!(
905            !rc.contains("export ANTHROPIC_BASE_URL="),
906            "ANTHROPIC export must be omitted in subscription mode"
907        );
908        assert!(
909            rc.contains(ANTHROPIC_OMITTED_NOTE),
910            "omission must be explained in the RC block"
911        );
912    }
913
914    /// Shell export: API-key mode includes the ANTHROPIC export (symmetry check).
915    #[test]
916    fn shell_export_includes_anthropic_with_key() {
917        if claude_dir_overridden() {
918            return;
919        }
920        let home = tempfile::tempdir().unwrap();
921        std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
922        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
923        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
924        let port = listener.local_addr().unwrap().port();
925
926        install_shell_exports(home.path(), port, true);
927
928        let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
929        assert!(
930            rc.contains(&format!(
931                "export ANTHROPIC_BASE_URL=\"http://127.0.0.1:{port}\""
932            )),
933            "API-key mode must export ANTHROPIC_BASE_URL"
934        );
935    }
936}