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    // OpenAI SDK convention: the base URL INCLUDES the `/v1` prefix (default is
276    // `https://api.openai.com/v1`); clients append bare endpoints like `/responses`.
277    // Without `/v1`, OpenCode's ChatGPT-OAuth plugin fails to recognize Responses-API
278    // requests (it matches on `/v1/responses`) and OAuth traffic leaks to the platform
279    // API with the wrong credential ("Missing scopes: api.responses.write", #366).
280    // Anthropic and Gemini SDKs expect a bare origin instead — they append `/v1/...`
281    // / `/v1beta/...` themselves.
282    let openai_base = format!("{base}/v1");
283
284    // Only route Claude through the proxy when an API key is available; a Pro/Max
285    // subscription must keep talking to api.anthropic.com directly (see
286    // `anthropic_api_key_available`).
287    let include_anthropic = anthropic_api_key_available(home);
288
289    let posix_anthropic = if include_anthropic {
290        format!(r#"export ANTHROPIC_BASE_URL="{base}""#)
291    } else {
292        format!("# {ANTHROPIC_OMITTED_NOTE}")
293    };
294    let posix_block = format!(
295        r#"{PROXY_ENV_START}
296{posix_anthropic}
297export OPENAI_BASE_URL="{openai_base}"
298export GEMINI_API_BASE_URL="{base}"
299{PROXY_ENV_END}"#
300    );
301
302    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
303        if rc.exists() {
304            let label = format!(
305                "proxy env in ~/{}",
306                rc.file_name().unwrap_or_default().to_string_lossy()
307            );
308            marked_block::upsert(
309                rc,
310                PROXY_ENV_START,
311                PROXY_ENV_END,
312                &posix_block,
313                quiet,
314                &label,
315            );
316        }
317    }
318
319    let fish_config = home.join(".config/fish/config.fish");
320    if fish_config.exists() {
321        let fish_anthropic = if include_anthropic {
322            format!(r#"set -gx ANTHROPIC_BASE_URL "{base}""#)
323        } else {
324            format!("# {ANTHROPIC_OMITTED_NOTE}")
325        };
326        let fish_block = format!(
327            r#"{PROXY_ENV_START}
328{fish_anthropic}
329set -gx OPENAI_BASE_URL "{openai_base}"
330set -gx GEMINI_API_BASE_URL "{base}"
331{PROXY_ENV_END}"#
332        );
333        marked_block::upsert(
334            &fish_config,
335            PROXY_ENV_START,
336            PROXY_ENV_END,
337            &fish_block,
338            quiet,
339            "proxy env in ~/.config/fish/config.fish",
340        );
341    }
342
343    let ps_profile = dirs::home_dir().map(|h| crate::shell::platform::powershell_profile_path(&h));
344    if let Some(ref ps) = ps_profile {
345        if ps.exists() {
346            let ps_anthropic = if include_anthropic {
347                format!(r#"$env:ANTHROPIC_BASE_URL = "{base}""#)
348            } else {
349                format!("# {ANTHROPIC_OMITTED_NOTE}")
350            };
351            let ps_block = format!(
352                r#"{PROXY_ENV_START}
353{ps_anthropic}
354$env:OPENAI_BASE_URL = "{openai_base}"
355$env:GEMINI_API_BASE_URL = "{base}"
356{PROXY_ENV_END}"#
357            );
358            marked_block::upsert(
359                ps,
360                PROXY_ENV_START,
361                PROXY_ENV_END,
362                &ps_block,
363                quiet,
364                "proxy env in PowerShell profile",
365            );
366        }
367    }
368}
369
370fn uninstall_claude_env(home: &Path, quiet: bool) {
371    use crate::core::config::Config;
372
373    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
374    let settings_path = settings_dir.join("settings.json");
375    let existing = match std::fs::read_to_string(&settings_path) {
376        Ok(s) if !s.trim().is_empty() => s,
377        _ => return,
378    };
379    let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
380        Ok(v) => v,
381        Err(_) => return,
382    };
383
384    let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
385        return;
386    };
387
388    if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
389        return;
390    }
391
392    let cfg = Config::load();
393    if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
394        env_obj.insert(
395            "ANTHROPIC_BASE_URL".to_string(),
396            serde_json::Value::String(upstream.clone()),
397        );
398        if !quiet {
399            println!("  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
400        }
401    } else {
402        env_obj.remove("ANTHROPIC_BASE_URL");
403        if env_obj.is_empty() {
404            doc.as_object_mut().map(|o| o.remove("env"));
405        }
406        if !quiet {
407            println!("  ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
408        }
409    }
410
411    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
412    let _ = std::fs::write(&settings_path, content + "\n");
413}
414
415fn uninstall_codex_env(home: &Path, quiet: bool) {
416    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
417    let config_path = codex_dir.join("config.toml");
418    let existing = match std::fs::read_to_string(&config_path) {
419        Ok(s) if !s.trim().is_empty() => s,
420        _ => return,
421    };
422
423    if !existing.contains("OPENAI_BASE_URL") {
424        return;
425    }
426
427    let cleaned: String = existing
428        .lines()
429        .filter(|line| {
430            let trimmed = line.trim();
431            !trimmed.starts_with("OPENAI_BASE_URL")
432        })
433        .collect::<Vec<_>>()
434        .join("\n");
435
436    let cleaned = cleaned
437        .replace("\n[env]\n\n", "\n")
438        .replace("[env]\n\n", "");
439    let cleaned = if cleaned.trim() == "[env]" {
440        String::new()
441    } else {
442        cleaned
443    };
444
445    let _ = std::fs::write(&config_path, &cleaned);
446    if !quiet {
447        println!("  ✓ Removed OPENAI_BASE_URL from Codex CLI config");
448    }
449}
450
451fn install_claude_env(home: &Path, port: u16, quiet: bool) {
452    install_claude_env_inner(home, port, quiet, false);
453}
454
455fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
456    use crate::core::config::{is_local_proxy_url, normalize_url_opt, Config};
457
458    let base = format!("http://127.0.0.1:{port}");
459
460    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
461    let settings_path = settings_dir.join("settings.json");
462    let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
463    let mut doc: serde_json::Value = if existing.trim().is_empty() {
464        serde_json::json!({})
465    } else {
466        match crate::core::jsonc::parse_jsonc(&existing) {
467            Ok(v) => v,
468            Err(_) => return,
469        }
470    };
471
472    let current_url = doc
473        .get("env")
474        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
475        .and_then(|v| v.as_str())
476        .unwrap_or("")
477        .to_string();
478
479    // SUBSCRIPTION GUARD: the proxy never injects credentials, so redirecting Claude
480    // Code only works in API-key mode. A Claude Pro/Max subscription (OAuth) is rejected
481    // by a custom ANTHROPIC_BASE_URL → login loop / 401. When no API key is detectable we
482    // must not point Claude Code at the proxy. `--force` overrides for power users whose
483    // key lives somewhere we cannot probe (e.g. a keychain or apiKeyHelper we missed).
484    if !force && !anthropic_api_key_available(home) {
485        // Repair an existing stale local redirect so Claude Code reaches Anthropic again.
486        if is_local_lean_ctx_url(&current_url) {
487            let cfg = Config::load();
488            if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
489                if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
490                    env_obj.insert(
491                        "ANTHROPIC_BASE_URL".to_string(),
492                        serde_json::Value::String(upstream.clone()),
493                    );
494                } else {
495                    env_obj.remove("ANTHROPIC_BASE_URL");
496                    if env_obj.is_empty() {
497                        doc.as_object_mut().map(|o| o.remove("env"));
498                    }
499                }
500                let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
501                let _ = std::fs::write(&settings_path, out + "\n");
502            }
503        }
504        if !quiet {
505            warn_claude_subscription_skip();
506        }
507        return;
508    }
509
510    if current_url == base {
511        if !quiet {
512            println!("  Claude Code proxy env already configured");
513        }
514        return;
515    }
516
517    // HARD GUARD: never overwrite non-local endpoints unless --force
518    if let Some(upstream) = normalize_url_opt(&current_url) {
519        if !is_local_proxy_url(&upstream) {
520            let mut cfg = Config::load();
521            if cfg.proxy.anthropic_upstream.is_none() {
522                cfg.proxy.anthropic_upstream = Some(upstream.clone());
523                let _ = cfg.save();
524            }
525
526            if !force {
527                if !quiet {
528                    eprintln!("  \u{26a0} Custom endpoint detected: {upstream}");
529                    eprintln!(
530                        "    Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
531                    );
532                }
533                return;
534            }
535            if !quiet {
536                println!("  Overriding custom endpoint (--force): {upstream}");
537            }
538        }
539    }
540
541    if !is_proxy_reachable(port) {
542        if !quiet {
543            println!("  Skipping Claude Code proxy env (proxy not running on port {port})");
544        }
545        return;
546    }
547
548    if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
549        o.entry("env")
550            .or_insert(serde_json::json!({}))
551            .as_object_mut()
552    }) {
553        env_obj.insert(
554            "ANTHROPIC_BASE_URL".to_string(),
555            serde_json::Value::String(base),
556        );
557    }
558
559    let _ = std::fs::create_dir_all(&settings_dir);
560    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
561    let _ = std::fs::write(&settings_path, content + "\n");
562    if !quiet {
563        println!("  Configured ANTHROPIC_BASE_URL in Claude Code settings");
564    }
565}
566
567/// Proxy reachability timeout. Priority: env var > config.toml > 200ms default.
568pub fn proxy_timeout() -> std::time::Duration {
569    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS") {
570        if let Ok(ms) = val.parse::<u64>() {
571            return std::time::Duration::from_millis(ms);
572        }
573    }
574    if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
575        return std::time::Duration::from_millis(ms);
576    }
577    std::time::Duration::from_millis(200)
578}
579
580fn is_proxy_reachable(port: u16) -> bool {
581    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
582    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
583    TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
584}
585
586fn install_codex_env(home: &Path, port: u16, quiet: bool) {
587    let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
588    install_codex_env_at(&config_dir, port, quiet);
589}
590
591/// Testable core of `install_codex_env`: operates on an explicit Codex config
592/// directory instead of resolving it from `CODEX_HOME` / the real home.
593fn install_codex_env_at(config_dir: &Path, port: u16, quiet: bool) {
594    // Codex CLI follows the OpenAI convention: base URL includes `/v1` (#366).
595    let base = format!("http://127.0.0.1:{port}");
596    let value = format!("{base}/v1");
597
598    if !is_proxy_reachable(port) {
599        if !quiet {
600            println!("  Skipping Codex CLI proxy env (proxy not running on port {port})");
601        }
602        return;
603    }
604
605    let config_path = config_dir.join("config.toml");
606
607    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
608
609    if existing.contains("OPENAI_BASE_URL") && existing.contains(&value) {
610        if !quiet {
611            println!("  Codex CLI proxy env already configured");
612        }
613        return;
614    }
615
616    if !config_dir.exists() {
617        return;
618    }
619
620    let mut content = existing;
621
622    if content.contains("OPENAI_BASE_URL") {
623        // Migrate stale local entries written without `/v1` by older versions.
624        content = content
625            .lines()
626            .map(|line| {
627                let trimmed = line.trim();
628                if trimmed.starts_with("OPENAI_BASE_URL")
629                    && (trimmed.contains("127.0.0.1") || trimmed.contains("localhost"))
630                {
631                    format!("OPENAI_BASE_URL = \"{value}\"")
632                } else {
633                    line.to_string()
634                }
635            })
636            .collect::<Vec<_>>()
637            .join("\n");
638        if !content.ends_with('\n') {
639            content.push('\n');
640        }
641    } else if content.contains("[env]") {
642        content = content.replace("[env]", &format!("[env]\nOPENAI_BASE_URL = \"{value}\""));
643    } else {
644        if !content.is_empty() && !content.ends_with('\n') {
645            content.push('\n');
646        }
647        content.push_str(&format!("\n[env]\nOPENAI_BASE_URL = \"{value}\"\n"));
648    }
649
650    let _ = std::fs::write(&config_path, &content);
651    if !quiet {
652        println!("  Configured OPENAI_BASE_URL in Codex CLI config");
653    }
654}
655
656pub fn default_port() -> u16 {
657    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT") {
658        if let Ok(port) = val.parse::<u16>() {
659            return port;
660        }
661    }
662    let cfg = crate::core::config::Config::load();
663    if let Some(port) = cfg.proxy_port {
664        return port;
665    }
666    uid_based_port()
667}
668
669/// Derives a deterministic port from the user's UID to avoid collisions
670/// on multi-user systems. uid 1000 → 4444, uid 1001 → 4445, etc.
671/// System accounts (uid < 1000) and root always get the base port 4444.
672fn uid_based_port() -> u16 {
673    #[cfg(unix)]
674    {
675        // SAFETY: `getuid` takes no arguments, always succeeds, and only reads
676        // the calling process's real UID — no preconditions, no UB.
677        let uid = unsafe { libc::getuid() } as u16;
678        let offset = uid.saturating_sub(1000) % 1000;
679        DEFAULT_PROXY_PORT + offset
680    }
681    #[cfg(not(unix))]
682    {
683        DEFAULT_PROXY_PORT
684    }
685}
686
687#[cfg(test)]
688mod tests {
689    use super::*;
690
691    #[test]
692    fn uid_port_first_regular_user() {
693        // uid 1000 (first regular user on most Linux) → base port
694        assert_eq!(DEFAULT_PROXY_PORT, 4444);
695    }
696
697    #[test]
698    fn uid_port_no_overflow() {
699        // Ensure port stays in valid range even with high UIDs
700        // uid 2999 → offset (2999-1000) % 1000 = 999 → port 5443
701        let port = DEFAULT_PROXY_PORT + 999;
702        assert_eq!(port, 5443);
703        assert!(port < u16::MAX);
704    }
705
706    #[test]
707    fn uid_port_system_accounts_get_base() {
708        // uid < 1000 → saturating_sub gives 0 → base port
709        let uid: u16 = 500;
710        let offset = uid.saturating_sub(1000) % 1000;
711        assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
712    }
713
714    #[test]
715    fn proxy_timeout_default_200ms() {
716        if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
717            return;
718        }
719        assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
720    }
721
722    #[test]
723    fn proxy_timeout_is_non_zero() {
724        let t = proxy_timeout();
725        assert!(t.as_millis() > 0);
726    }
727
728    #[test]
729    fn is_proxy_reachable_returns_false_on_unused_port() {
730        assert!(!is_proxy_reachable(19999));
731    }
732
733    #[test]
734    fn posix_block_contains_all_provider_env_vars() {
735        let base = "http://127.0.0.1:4444";
736        let block = format!(
737            r#"{PROXY_ENV_START}
738export ANTHROPIC_BASE_URL="{base}"
739export OPENAI_BASE_URL="{base}/v1"
740export GEMINI_API_BASE_URL="{base}"
741{PROXY_ENV_END}"#
742        );
743        assert!(
744            block.contains("ANTHROPIC_BASE_URL"),
745            "shell exports must include ANTHROPIC_BASE_URL"
746        );
747        assert!(
748            block.contains("OPENAI_BASE_URL"),
749            "shell exports must include OPENAI_BASE_URL"
750        );
751        assert!(
752            block.contains("GEMINI_API_BASE_URL"),
753            "shell exports must include GEMINI_API_BASE_URL"
754        );
755    }
756
757    #[test]
758    fn fish_block_contains_all_provider_env_vars() {
759        let base = "http://127.0.0.1:4444";
760        let block = format!(
761            r#"{PROXY_ENV_START}
762set -gx ANTHROPIC_BASE_URL "{base}"
763set -gx OPENAI_BASE_URL "{base}/v1"
764set -gx GEMINI_API_BASE_URL "{base}"
765{PROXY_ENV_END}"#
766        );
767        assert!(block.contains("ANTHROPIC_BASE_URL"));
768        assert!(block.contains("OPENAI_BASE_URL"));
769        assert!(block.contains("GEMINI_API_BASE_URL"));
770    }
771
772    #[test]
773    fn powershell_block_contains_all_provider_env_vars() {
774        let base = "http://127.0.0.1:4444";
775        let block = format!(
776            r#"{PROXY_ENV_START}
777$env:ANTHROPIC_BASE_URL = "{base}"
778$env:OPENAI_BASE_URL = "{base}/v1"
779$env:GEMINI_API_BASE_URL = "{base}"
780{PROXY_ENV_END}"#
781        );
782        assert!(block.contains("ANTHROPIC_BASE_URL"));
783        assert!(block.contains("OPENAI_BASE_URL"));
784        assert!(block.contains("GEMINI_API_BASE_URL"));
785    }
786
787    /// The subscription guard reads the process environment; these tests are only
788    /// meaningful when the test runner itself does not provide an Anthropic key.
789    fn env_provides_anthropic_key() -> bool {
790        std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty())
791            || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok_and(|v| !v.trim().is_empty())
792    }
793
794    /// `claude_state_dir` honours `CLAUDE_CONFIG_DIR`; when set it would escape the
795    /// temp HOME and read the real settings file, so skip in that case.
796    fn claude_dir_overridden() -> bool {
797        std::env::var("CLAUDE_CONFIG_DIR").is_ok_and(|v| !v.trim().is_empty())
798    }
799
800    fn write_claude_settings(home: &Path, json: &str) -> std::path::PathBuf {
801        let dir = home.join(".claude");
802        std::fs::create_dir_all(&dir).unwrap();
803        let path = dir.join("settings.json");
804        std::fs::write(&path, json).unwrap();
805        path
806    }
807
808    #[test]
809    fn api_key_available_true_with_api_key_helper() {
810        if claude_dir_overridden() {
811            return;
812        }
813        let home = tempfile::tempdir().unwrap();
814        write_claude_settings(home.path(), r#"{"apiKeyHelper": "echo sk-test"}"#);
815        assert!(anthropic_api_key_available(home.path()));
816    }
817
818    #[test]
819    fn api_key_available_true_with_settings_env_key() {
820        if claude_dir_overridden() {
821            return;
822        }
823        let home = tempfile::tempdir().unwrap();
824        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
825        assert!(anthropic_api_key_available(home.path()));
826    }
827
828    #[test]
829    fn api_key_available_false_without_key() {
830        if env_provides_anthropic_key() || claude_dir_overridden() {
831            return;
832        }
833        let home = tempfile::tempdir().unwrap();
834        write_claude_settings(home.path(), r#"{"env": {}}"#);
835        assert!(!anthropic_api_key_available(home.path()));
836    }
837
838    #[test]
839    fn api_key_available_false_when_no_settings_file() {
840        if env_provides_anthropic_key() || claude_dir_overridden() {
841            return;
842        }
843        let home = tempfile::tempdir().unwrap();
844        assert!(!anthropic_api_key_available(home.path()));
845    }
846
847    #[test]
848    fn subscription_guard_skips_redirect_without_key() {
849        if env_provides_anthropic_key() || claude_dir_overridden() {
850            return;
851        }
852        let home = tempfile::tempdir().unwrap();
853        // No settings file → subscription mode, empty current URL → nothing to repair.
854        install_claude_env_inner(home.path(), 4444, true, false);
855        let settings = home.path().join(".claude/settings.json");
856        assert!(
857            !settings.exists(),
858            "subscription mode must not write a proxy redirect"
859        );
860    }
861
862    #[test]
863    fn subscription_guard_repairs_stale_local_redirect() {
864        if env_provides_anthropic_key() || claude_dir_overridden() {
865            return;
866        }
867        let home = tempfile::tempdir().unwrap();
868        let path = write_claude_settings(
869            home.path(),
870            r#"{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4444"}}"#,
871        );
872        install_claude_env_inner(home.path(), 4444, true, false);
873        let after = std::fs::read_to_string(&path).unwrap();
874        let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
875        let base = doc
876            .get("env")
877            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
878            .and_then(|v| v.as_str())
879            .unwrap_or("");
880        assert!(
881            !is_local_lean_ctx_url(base),
882            "stale local redirect must be repaired in subscription mode, got {base:?}"
883        );
884    }
885
886    /// API-key mode must STILL route Claude through the proxy (we only protect
887    /// subscriptions; pay-as-you-go users keep their compression). Uses a real bound
888    /// port so `is_proxy_reachable` passes, exercising the full production path.
889    #[test]
890    fn install_redirects_claude_when_api_key_present() {
891        if claude_dir_overridden() {
892            return;
893        }
894        let home = tempfile::tempdir().unwrap();
895        // API-key mode declared in settings.json → deterministic regardless of env.
896        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
897        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
898        let port = listener.local_addr().unwrap().port();
899
900        install_claude_env_inner(home.path(), port, true, false);
901
902        let after = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
903        let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
904        let base = doc
905            .get("env")
906            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
907            .and_then(|v| v.as_str())
908            .unwrap_or("");
909        assert_eq!(
910            base,
911            format!("http://127.0.0.1:{port}"),
912            "API-key mode must route Claude through the proxy"
913        );
914    }
915
916    /// Shell export: subscription mode keeps OpenAI/Gemini but omits the ANTHROPIC line
917    /// (replaced by an explanatory comment), so a shell-launched Claude stays on
918    /// api.anthropic.com.
919    #[test]
920    fn shell_export_omits_anthropic_without_key() {
921        if env_provides_anthropic_key() || claude_dir_overridden() {
922            return;
923        }
924        let home = tempfile::tempdir().unwrap();
925        std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
926        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
927        let port = listener.local_addr().unwrap().port();
928
929        install_shell_exports(home.path(), port, true);
930
931        let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
932        assert!(
933            rc.contains(&format!(
934                "export OPENAI_BASE_URL=\"http://127.0.0.1:{port}/v1\""
935            )),
936            "OpenAI export must remain and carry the /v1 suffix (#366)"
937        );
938        assert!(
939            rc.contains(&format!(
940                "export GEMINI_API_BASE_URL=\"http://127.0.0.1:{port}\""
941            )),
942            "Gemini export must remain WITHOUT /v1 (SDK appends /v1beta itself)"
943        );
944        assert!(
945            !rc.contains("export ANTHROPIC_BASE_URL="),
946            "ANTHROPIC export must be omitted in subscription mode"
947        );
948        assert!(
949            rc.contains(ANTHROPIC_OMITTED_NOTE),
950            "omission must be explained in the RC block"
951        );
952    }
953
954    /// Codex CLI config: a fresh install writes the `/v1`-suffixed proxy URL (#366).
955    #[test]
956    fn codex_env_writes_v1_suffixed_url() {
957        let dir = tempfile::tempdir().unwrap();
958        let codex_dir = dir.path().join(".codex");
959        std::fs::create_dir_all(&codex_dir).unwrap();
960        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
961        let port = listener.local_addr().unwrap().port();
962
963        install_codex_env_at(&codex_dir, port, true);
964
965        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
966        assert!(
967            cfg.contains(&format!("OPENAI_BASE_URL = \"http://127.0.0.1:{port}/v1\"")),
968            "Codex config must carry the /v1 suffix, got:\n{cfg}"
969        );
970    }
971
972    /// Codex CLI config: a stale local entry without `/v1` (written by older
973    /// versions) is migrated in place instead of being treated as configured.
974    #[test]
975    fn codex_env_migrates_stale_entry_without_v1() {
976        let dir = tempfile::tempdir().unwrap();
977        let codex_dir = dir.path().join(".codex");
978        std::fs::create_dir_all(&codex_dir).unwrap();
979        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
980        let port = listener.local_addr().unwrap().port();
981        std::fs::write(
982            codex_dir.join("config.toml"),
983            format!(
984                "model = \"gpt-5.2\"\n\n[env]\nOPENAI_BASE_URL = \"http://127.0.0.1:{port}\"\n"
985            ),
986        )
987        .unwrap();
988
989        install_codex_env_at(&codex_dir, port, true);
990
991        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
992        assert!(
993            cfg.contains(&format!("OPENAI_BASE_URL = \"http://127.0.0.1:{port}/v1\"")),
994            "stale entry must be migrated to the /v1 form, got:\n{cfg}"
995        );
996        assert!(
997            cfg.contains("model = \"gpt-5.2\""),
998            "unrelated config must be preserved"
999        );
1000    }
1001
1002    /// Codex CLI config: a custom non-local endpoint is never rewritten.
1003    #[test]
1004    fn codex_env_preserves_custom_remote_endpoint() {
1005        let dir = tempfile::tempdir().unwrap();
1006        let codex_dir = dir.path().join(".codex");
1007        std::fs::create_dir_all(&codex_dir).unwrap();
1008        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1009        let port = listener.local_addr().unwrap().port();
1010        let original = "[env]\nOPENAI_BASE_URL = \"https://my-gateway.example.com/v1\"\n";
1011        std::fs::write(codex_dir.join("config.toml"), original).unwrap();
1012
1013        install_codex_env_at(&codex_dir, port, true);
1014
1015        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1016        assert!(
1017            cfg.contains("https://my-gateway.example.com/v1"),
1018            "custom remote endpoint must be preserved, got:\n{cfg}"
1019        );
1020        assert!(
1021            !cfg.contains("127.0.0.1"),
1022            "proxy URL must not be injected over a custom endpoint"
1023        );
1024    }
1025
1026    /// Shell export: API-key mode includes the ANTHROPIC export (symmetry check).
1027    #[test]
1028    fn shell_export_includes_anthropic_with_key() {
1029        if claude_dir_overridden() {
1030            return;
1031        }
1032        let home = tempfile::tempdir().unwrap();
1033        std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1034        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1035        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1036        let port = listener.local_addr().unwrap().port();
1037
1038        install_shell_exports(home.path(), port, true);
1039
1040        let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1041        assert!(
1042            rc.contains(&format!(
1043                "export ANTHROPIC_BASE_URL=\"http://127.0.0.1:{port}\""
1044            )),
1045            "API-key mode must export ANTHROPIC_BASE_URL"
1046        );
1047    }
1048}