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 = "ANTHROPIC_BASE_URL omitted: Claude Pro/Max subscription authenticates against api.anthropic.com directly (set ANTHROPIC_API_KEY to route Claude through the proxy)";
15
16pub fn install_proxy_env(home: &Path, port: u16, quiet: bool) {
17    let cfg = crate::core::config::Config::load();
18    if cfg.proxy_enabled != Some(true) {
19        if !quiet {
20            println!("  Proxy env skipped (not enabled in config)");
21        }
22        return;
23    }
24    install_shell_exports(home, port, quiet);
25    install_claude_env(home, port, quiet);
26    install_codex_env(home, port, quiet);
27    install_pi_env(home, port, quiet, false);
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    install_pi_env(home, port, quiet, force_endpoint);
41}
42
43pub fn preview_proxy_cleanup(home: &Path) {
44    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
45    let settings_path = settings_dir.join("settings.json");
46    if let Ok(content) = std::fs::read_to_string(&settings_path)
47        && content.contains("ANTHROPIC_BASE_URL")
48    {
49        let cfg = crate::core::config::Config::load();
50        if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
51            println!("  Would restore ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
52        } else {
53            println!("  Would remove ANTHROPIC_BASE_URL from Claude Code settings");
54        }
55    }
56
57    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
58    let codex_path = codex_dir.join("config.toml");
59    if let Ok(content) = std::fs::read_to_string(codex_path)
60        && codex_config_has_local_proxy_entry(&content)
61    {
62        println!("  Would remove Codex proxy URL from config.toml");
63    }
64}
65
66/// Removes stale proxy URLs from Claude Code / Codex settings when the proxy is not enabled.
67/// Returns the number of stale URLs cleaned up.
68pub fn cleanup_stale_proxy_env(home: &Path) -> usize {
69    let cfg = crate::core::config::Config::load();
70    if cfg.proxy_enabled == Some(true) {
71        return 0;
72    }
73
74    let mut cleaned = 0;
75
76    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
77    let settings_path = settings_dir.join("settings.json");
78    if let Ok(content) = std::fs::read_to_string(&settings_path)
79        && let Ok(mut doc) = crate::core::jsonc::parse_jsonc(&content)
80        && let Some(base_url) = doc
81            .get("env")
82            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
83            .and_then(|v| v.as_str())
84            .map(String::from)
85        && is_local_lean_ctx_url(&base_url)
86        && let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut())
87    {
88        if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
89            env_obj.insert(
90                "ANTHROPIC_BASE_URL".to_string(),
91                serde_json::Value::String(upstream.clone()),
92            );
93            println!("  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
94        } else {
95            env_obj.remove("ANTHROPIC_BASE_URL");
96            if env_obj.is_empty() {
97                doc.as_object_mut().map(|o| o.remove("env"));
98            }
99            println!("  ✓ Removed stale ANTHROPIC_BASE_URL from Claude Code settings");
100        }
101        let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
102        let _ = std::fs::write(&settings_path, out + "\n");
103        cleaned += 1;
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        && codex_config_has_local_proxy_entry(&content)
110    {
111        let filtered = strip_codex_proxy_entries(&content);
112        let _ = std::fs::write(&codex_path, &filtered);
113        println!("  ✓ Removed stale Codex proxy URL from config.toml");
114        cleaned += 1;
115    }
116
117    cleaned
118}
119
120pub fn is_local_lean_ctx_url(url: &str) -> bool {
121    url.starts_with("http://127.0.0.1:") || url.starts_with("http://localhost:")
122}
123
124/// Returns true if Claude Code settings contain a local ANTHROPIC_BASE_URL
125/// while the proxy is not enabled (stale configuration).
126pub fn has_stale_proxy_url(home: &Path) -> bool {
127    let cfg = crate::core::config::Config::load();
128    if cfg.proxy_enabled == Some(true) {
129        return false;
130    }
131
132    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
133    let settings_path = settings_dir.join("settings.json");
134    let Ok(content) = std::fs::read_to_string(&settings_path) else {
135        return false;
136    };
137    let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
138        return false;
139    };
140
141    let base_url = doc
142        .get("env")
143        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
144        .and_then(|v| v.as_str())
145        .unwrap_or("");
146
147    is_local_lean_ctx_url(base_url)
148}
149
150/// Returns true when an Anthropic **API key** is available for the proxy to forward
151/// upstream.
152///
153/// The proxy never injects credentials (see `proxy/forward.rs` — only
154/// `ALLOWED_REQUEST_HEADERS` are forwarded), so it can only help Claude Code when the
155/// user runs in API-key (pay-as-you-go) mode. A Claude **Pro/Max subscription**
156/// authenticates via OAuth directly against `api.anthropic.com`; that token is rejected
157/// by any custom `ANTHROPIC_BASE_URL`, so redirecting subscription traffic through the
158/// proxy only breaks auth (login loop / 401). When this returns `false`, callers must
159/// NOT point Claude Code at the proxy.
160pub fn anthropic_api_key_available(home: &Path) -> bool {
161    // 1) Process environment — covers shells and Claude Code launched from them.
162    for var in ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"] {
163        if std::env::var(var).is_ok_and(|v| !v.trim().is_empty()) {
164            return true;
165        }
166    }
167
168    // 2) Claude Code settings.json — an explicit key, an auth token, or a dynamic
169    //    key helper all indicate API-key mode.
170    let settings_path = crate::core::editor_registry::claude_state_dir(home).join("settings.json");
171    let Ok(content) = std::fs::read_to_string(&settings_path) else {
172        return false;
173    };
174    let Ok(doc) = crate::core::jsonc::parse_jsonc(&content) else {
175        return false;
176    };
177
178    if doc
179        .get("apiKeyHelper")
180        .and_then(|v| v.as_str())
181        .is_some_and(|v| !v.trim().is_empty())
182    {
183        return true;
184    }
185
186    let env = doc.get("env");
187    ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]
188        .iter()
189        .any(|key| {
190            env.and_then(|e| e.get(*key))
191                .and_then(|v| v.as_str())
192                .is_some_and(|v| !v.trim().is_empty())
193        })
194}
195
196/// Explains why Claude Code was left pointing at `api.anthropic.com` instead of the
197/// proxy: a Pro/Max subscription (OAuth) cannot authenticate through a custom base URL.
198fn warn_claude_subscription_skip() {
199    eprintln!("  \u{26a0} Claude Code: no ANTHROPIC_API_KEY detected (Pro/Max subscription?).");
200    eprintln!("    The proxy forwards your credential upstream but never injects one, and a");
201    eprintln!("    subscription token only authenticates against api.anthropic.com directly.");
202    eprintln!("    Leaving ANTHROPIC_BASE_URL untouched so Claude Code keeps working.");
203    eprintln!("    Savings on a subscription: use the lean-ctx MCP tools (ctx_read /");
204    eprintln!("    ctx_search / ctx_shell). Pay-as-you-go? Set ANTHROPIC_API_KEY, then run:");
205    eprintln!("      lean-ctx proxy enable");
206}
207
208pub fn uninstall_proxy_env(home: &Path, quiet: bool) {
209    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
210        let label = format!(
211            "proxy env from ~/{}",
212            rc.file_name().unwrap_or_default().to_string_lossy()
213        );
214        marked_block::remove_from_file(rc, PROXY_ENV_START, PROXY_ENV_END, quiet, &label);
215    }
216
217    let fish_config = home.join(".config/fish/config.fish");
218    if fish_config.exists() {
219        marked_block::remove_from_file(
220            &fish_config,
221            PROXY_ENV_START,
222            PROXY_ENV_END,
223            quiet,
224            "proxy env from ~/.config/fish/config.fish",
225        );
226    }
227
228    let ps_profile =
229        dirs::home_dir().map(|h| crate::shell::platform::resolve_powershell_profile_path(&h));
230    if let Some(ref ps) = ps_profile
231        && ps.exists()
232    {
233        marked_block::remove_from_file(
234            ps,
235            PROXY_ENV_START,
236            PROXY_ENV_END,
237            quiet,
238            "proxy env from PowerShell profile",
239        );
240    }
241
242    uninstall_claude_env(home, quiet);
243    uninstall_codex_env(home, quiet);
244    uninstall_pi_env(home, quiet);
245}
246
247fn install_shell_exports(home: &Path, port: u16, quiet: bool) {
248    if !is_proxy_reachable(port) {
249        if !quiet {
250            println!("  Skipping shell proxy exports (proxy not running on port {port})");
251        }
252        return;
253    }
254
255    let base = format!("http://127.0.0.1:{port}");
256    // OpenAI SDK convention: the base URL INCLUDES the `/v1` prefix (default is
257    // `https://api.openai.com/v1`); clients append bare endpoints like `/responses`.
258    // Without `/v1`, OpenCode's ChatGPT-OAuth plugin fails to recognize Responses-API
259    // requests (it matches on `/v1/responses`) and OAuth traffic leaks to the platform
260    // API with the wrong credential ("Missing scopes: api.responses.write", #366).
261    // Anthropic and Gemini SDKs expect a bare origin instead — they append `/v1/...`
262    // / `/v1beta/...` themselves.
263    let openai_base = format!("{base}/v1");
264
265    // Only route Claude through the proxy when an API key is available; a Pro/Max
266    // subscription must keep talking to api.anthropic.com directly (see
267    // `anthropic_api_key_available`).
268    let include_anthropic = anthropic_api_key_available(home);
269
270    let posix_anthropic = if include_anthropic {
271        format!(r#"export ANTHROPIC_BASE_URL="{base}""#)
272    } else {
273        format!("# {ANTHROPIC_OMITTED_NOTE}")
274    };
275    let posix_block = format!(
276        r#"{PROXY_ENV_START}
277{posix_anthropic}
278export OPENAI_BASE_URL="{openai_base}"
279export GEMINI_API_BASE_URL="{base}"
280{PROXY_ENV_END}"#
281    );
282
283    for rc in &[home.join(".zshrc"), home.join(".bashrc")] {
284        if rc.exists() {
285            let label = format!(
286                "proxy env in ~/{}",
287                rc.file_name().unwrap_or_default().to_string_lossy()
288            );
289            marked_block::upsert(
290                rc,
291                PROXY_ENV_START,
292                PROXY_ENV_END,
293                &posix_block,
294                quiet,
295                &label,
296            );
297        }
298    }
299
300    let fish_config = home.join(".config/fish/config.fish");
301    if fish_config.exists() {
302        let fish_anthropic = if include_anthropic {
303            format!(r#"set -gx ANTHROPIC_BASE_URL "{base}""#)
304        } else {
305            format!("# {ANTHROPIC_OMITTED_NOTE}")
306        };
307        let fish_block = format!(
308            r#"{PROXY_ENV_START}
309{fish_anthropic}
310set -gx OPENAI_BASE_URL "{openai_base}"
311set -gx GEMINI_API_BASE_URL "{base}"
312{PROXY_ENV_END}"#
313        );
314        marked_block::upsert(
315            &fish_config,
316            PROXY_ENV_START,
317            PROXY_ENV_END,
318            &fish_block,
319            quiet,
320            "proxy env in ~/.config/fish/config.fish",
321        );
322    }
323
324    let ps_profile =
325        dirs::home_dir().map(|h| crate::shell::platform::resolve_powershell_profile_path(&h));
326    if let Some(ref ps) = ps_profile
327        && ps.exists()
328    {
329        let ps_anthropic = if include_anthropic {
330            format!(r#"$env:ANTHROPIC_BASE_URL = "{base}""#)
331        } else {
332            format!("# {ANTHROPIC_OMITTED_NOTE}")
333        };
334        let ps_block = format!(
335            r#"{PROXY_ENV_START}
336{ps_anthropic}
337$env:OPENAI_BASE_URL = "{openai_base}"
338$env:GEMINI_API_BASE_URL = "{base}"
339{PROXY_ENV_END}"#
340        );
341        marked_block::upsert(
342            ps,
343            PROXY_ENV_START,
344            PROXY_ENV_END,
345            &ps_block,
346            quiet,
347            "proxy env in PowerShell profile",
348        );
349    }
350}
351
352fn uninstall_claude_env(home: &Path, quiet: bool) {
353    use crate::core::config::Config;
354
355    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
356    let settings_path = settings_dir.join("settings.json");
357    let existing = match std::fs::read_to_string(&settings_path) {
358        Ok(s) if !s.trim().is_empty() => s,
359        _ => return,
360    };
361    let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
362        Ok(v) => v,
363        Err(_) => return,
364    };
365
366    let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) else {
367        return;
368    };
369
370    if !env_obj.contains_key("ANTHROPIC_BASE_URL") {
371        return;
372    }
373
374    let cfg = Config::load();
375    if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
376        env_obj.insert(
377            "ANTHROPIC_BASE_URL".to_string(),
378            serde_json::Value::String(upstream.clone()),
379        );
380        if !quiet {
381            println!("  ✓ Restored ANTHROPIC_BASE_URL → {upstream} in Claude Code settings");
382        }
383    } else {
384        env_obj.remove("ANTHROPIC_BASE_URL");
385        if env_obj.is_empty() {
386            doc.as_object_mut().map(|o| o.remove("env"));
387        }
388        if !quiet {
389            println!("  ✓ Removed ANTHROPIC_BASE_URL from Claude Code settings");
390        }
391    }
392
393    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
394    let _ = std::fs::write(&settings_path, content + "\n");
395}
396
397fn uninstall_codex_env(home: &Path, quiet: bool) {
398    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
399    let config_path = codex_dir.join("config.toml");
400    let existing = match std::fs::read_to_string(&config_path) {
401        Ok(s) if !s.trim().is_empty() => s,
402        _ => return,
403    };
404
405    let has_local = codex_config_has_local_proxy_entry(&existing);
406    if !has_local {
407        return;
408    }
409
410    let cleaned = strip_codex_proxy_entries(&existing);
411    let _ = std::fs::write(&config_path, &cleaned);
412    if !quiet {
413        println!("  ✓ Removed Codex proxy URL(s) from Codex CLI config");
414    }
415}
416
417/// Pi / forge resolve their provider endpoint from `~/.pi/agent/models.json`
418/// (`providers.<name>.baseUrl`) + OAuth, *not* from `ANTHROPIC_BASE_URL` /
419/// `OPENAI_BASE_URL`, so the shell and Claude/Codex wiring never reaches them
420/// (an independent benchmark found `proxy enable` silently bypassed for forge,
421/// #361). Point Pi's providers at the proxy directly instead. Unlike a Claude
422/// Code Pro/Max subscription — which a custom base URL breaks — Pi's OAuth works
423/// through the proxy, because the proxy forwards the credential verbatim to the
424/// real upstream (verified field-for-field in #361), so no API-key guard applies.
425fn install_pi_env(home: &Path, port: u16, quiet: bool, force: bool) {
426    install_pi_env_at(&home.join(".pi/agent"), port, quiet, force);
427}
428
429fn uninstall_pi_env(home: &Path, quiet: bool) {
430    uninstall_pi_env_at(&home.join(".pi/agent"), quiet);
431}
432
433/// Testable core of [`install_pi_env`]: operates on an explicit `~/.pi/agent`
434/// directory. Wires both providers using the same per-SDK conventions as the
435/// shell exports — Anthropic gets the bare origin (it appends `/v1` itself),
436/// OpenAI gets the `/v1`-suffixed URL (#366). A custom *remote* endpoint is
437/// preserved unless `force`, and only the providers we actually rewrite are
438/// touched, so the file round-trips cleanly on `disable`.
439fn install_pi_env_at(agent_dir: &Path, port: u16, quiet: bool, force: bool) {
440    use crate::core::config::{is_local_proxy_url, normalize_url_opt};
441
442    // Only wire Pi when it is actually configured on this machine.
443    if !agent_dir.exists() {
444        return;
445    }
446    if !is_proxy_reachable(port) {
447        if !quiet {
448            println!("  Skipping Pi proxy env (proxy not running on port {port})");
449        }
450        return;
451    }
452
453    let base = format!("http://127.0.0.1:{port}");
454    let models_path = agent_dir.join("models.json");
455    let existing = std::fs::read_to_string(&models_path).unwrap_or_default();
456    let mut doc: serde_json::Value = if existing.trim().is_empty() {
457        serde_json::json!({})
458    } else {
459        match crate::core::jsonc::parse_jsonc(&existing) {
460            Ok(v) => v,
461            Err(_) => return,
462        }
463    };
464
465    let mut changed = false;
466    let mut kept_custom: Vec<String> = Vec::new();
467    for (provider, proxy_url) in [
468        ("anthropic", base.clone()),
469        ("openai", format!("{base}/v1")),
470    ] {
471        let current = pi_provider_base_url(&doc, provider).to_string();
472        if current == proxy_url {
473            continue;
474        }
475        // Never silently clobber a user's custom remote gateway; --force overrides.
476        if !force
477            && let Some(custom) = normalize_url_opt(&current)
478            && !is_local_proxy_url(&custom)
479        {
480            kept_custom.push(format!("{provider} → {custom}"));
481            continue;
482        }
483        set_pi_provider_base_url(&mut doc, provider, &proxy_url);
484        changed = true;
485    }
486
487    if changed {
488        let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
489        let _ = std::fs::write(&models_path, out + "\n");
490        if !quiet {
491            println!(
492                "  Configured Pi providers (anthropic/openai) → proxy in ~/.pi/agent/models.json"
493            );
494        }
495    }
496    if !quiet && !kept_custom.is_empty() {
497        eprintln!(
498            "  \u{26a0} Pi: kept custom endpoint(s) {}; use `lean-ctx proxy enable --force` to override.",
499            kept_custom.join(", ")
500        );
501    }
502}
503
504/// Testable core of [`uninstall_pi_env`]. Reverts only the providers whose
505/// `baseUrl` still points at the local proxy (i.e. the ones we set), so a custom
506/// remote endpoint the user configured themselves is never removed.
507fn uninstall_pi_env_at(agent_dir: &Path, quiet: bool) {
508    use crate::core::config::is_local_proxy_url;
509
510    let models_path = agent_dir.join("models.json");
511    let existing = match std::fs::read_to_string(&models_path) {
512        Ok(s) if !s.trim().is_empty() => s,
513        _ => return,
514    };
515    let mut doc: serde_json::Value = match crate::core::jsonc::parse_jsonc(&existing) {
516        Ok(v) => v,
517        Err(_) => return,
518    };
519
520    let mut changed = false;
521    for provider in ["anthropic", "openai"] {
522        if is_local_proxy_url(pi_provider_base_url(&doc, provider))
523            && remove_pi_provider_base_url(&mut doc, provider)
524        {
525            changed = true;
526        }
527    }
528
529    if changed {
530        let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
531        let _ = std::fs::write(&models_path, out + "\n");
532        if !quiet {
533            println!("  \u{2713} Removed Pi proxy endpoints from ~/.pi/agent/models.json");
534        }
535    }
536}
537
538/// `providers.<name>.baseUrl` from a Pi `models.json` document (`""` if absent).
539fn pi_provider_base_url<'a>(doc: &'a serde_json::Value, provider: &str) -> &'a str {
540    doc.get("providers")
541        .and_then(|p| p.get(provider))
542        .and_then(|p| p.get("baseUrl"))
543        .and_then(serde_json::Value::as_str)
544        .unwrap_or("")
545}
546
547/// Sets `providers.<name>.baseUrl`, creating the nested objects as needed.
548fn set_pi_provider_base_url(doc: &mut serde_json::Value, provider: &str, url: &str) {
549    let Some(root) = doc.as_object_mut() else {
550        return;
551    };
552    let providers = root
553        .entry("providers")
554        .or_insert_with(|| serde_json::json!({}));
555    let Some(providers) = providers.as_object_mut() else {
556        return;
557    };
558    let entry = providers
559        .entry(provider.to_string())
560        .or_insert_with(|| serde_json::json!({}));
561    if let Some(entry) = entry.as_object_mut() {
562        entry.insert(
563            "baseUrl".to_string(),
564            serde_json::Value::String(url.to_string()),
565        );
566    }
567}
568
569/// Removes `providers.<name>.baseUrl` and prunes now-empty parent objects.
570/// Returns whether anything was removed.
571fn remove_pi_provider_base_url(doc: &mut serde_json::Value, provider: &str) -> bool {
572    let Some(root) = doc.as_object_mut() else {
573        return false;
574    };
575    let Some(providers) = root.get_mut("providers").and_then(|p| p.as_object_mut()) else {
576        return false;
577    };
578    let Some(entry) = providers.get_mut(provider).and_then(|p| p.as_object_mut()) else {
579        return false;
580    };
581    if entry.remove("baseUrl").is_none() {
582        return false;
583    }
584    if entry.is_empty() {
585        providers.remove(provider);
586    }
587    if providers.is_empty() {
588        root.remove("providers");
589    }
590    true
591}
592
593fn install_claude_env(home: &Path, port: u16, quiet: bool) {
594    install_claude_env_inner(home, port, quiet, false);
595}
596
597fn install_claude_env_inner(home: &Path, port: u16, quiet: bool, force: bool) {
598    use crate::core::config::{Config, is_local_proxy_url, normalize_url_opt};
599
600    let base = format!("http://127.0.0.1:{port}");
601
602    let settings_dir = crate::core::editor_registry::claude_state_dir(home);
603    let settings_path = settings_dir.join("settings.json");
604    let existing = std::fs::read_to_string(&settings_path).unwrap_or_default();
605    let mut doc: serde_json::Value = if existing.trim().is_empty() {
606        serde_json::json!({})
607    } else {
608        match crate::core::jsonc::parse_jsonc(&existing) {
609            Ok(v) => v,
610            Err(_) => return,
611        }
612    };
613
614    let current_url = doc
615        .get("env")
616        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
617        .and_then(|v| v.as_str())
618        .unwrap_or("")
619        .to_string();
620
621    // SUBSCRIPTION GUARD: the proxy never injects credentials, so redirecting Claude
622    // Code only works in API-key mode. A Claude Pro/Max subscription (OAuth) is rejected
623    // by a custom ANTHROPIC_BASE_URL → login loop / 401. When no API key is detectable we
624    // must not point Claude Code at the proxy. `--force` overrides for power users whose
625    // key lives somewhere we cannot probe (e.g. a keychain or apiKeyHelper we missed).
626    if !force && !anthropic_api_key_available(home) {
627        // Repair an existing stale local redirect so Claude Code reaches Anthropic again.
628        if is_local_lean_ctx_url(&current_url) {
629            let cfg = Config::load();
630            if let Some(env_obj) = doc.get_mut("env").and_then(|e| e.as_object_mut()) {
631                if let Some(ref upstream) = cfg.proxy.anthropic_upstream {
632                    env_obj.insert(
633                        "ANTHROPIC_BASE_URL".to_string(),
634                        serde_json::Value::String(upstream.clone()),
635                    );
636                } else {
637                    env_obj.remove("ANTHROPIC_BASE_URL");
638                    if env_obj.is_empty() {
639                        doc.as_object_mut().map(|o| o.remove("env"));
640                    }
641                }
642                let out = serde_json::to_string_pretty(&doc).unwrap_or_default();
643                let _ = std::fs::write(&settings_path, out + "\n");
644            }
645        }
646        if !quiet {
647            warn_claude_subscription_skip();
648        }
649        return;
650    }
651
652    if current_url == base {
653        if !quiet {
654            println!("  Claude Code proxy env already configured");
655        }
656        return;
657    }
658
659    // HARD GUARD: never overwrite non-local endpoints unless --force
660    if let Some(upstream) = normalize_url_opt(&current_url)
661        && !is_local_proxy_url(&upstream)
662    {
663        if Config::load_global().proxy.anthropic_upstream.is_none()
664            && let Err(e) =
665                Config::update_global(|c| c.proxy.anthropic_upstream = Some(upstream.clone()))
666        {
667            tracing::warn!("could not persist proxy upstream: {e}");
668        }
669
670        if !force {
671            if !quiet {
672                eprintln!("  \u{26a0} Custom endpoint detected: {upstream}");
673                eprintln!(
674                    "    Skipping proxy URL write. Use `lean-ctx proxy enable --force` to override."
675                );
676            }
677            return;
678        }
679        if !quiet {
680            println!("  Overriding custom endpoint (--force): {upstream}");
681        }
682    }
683
684    if !is_proxy_reachable(port) {
685        if !quiet {
686            println!("  Skipping Claude Code proxy env (proxy not running on port {port})");
687        }
688        return;
689    }
690
691    if let Some(env_obj) = doc.as_object_mut().and_then(|o| {
692        o.entry("env")
693            .or_insert(serde_json::json!({}))
694            .as_object_mut()
695    }) {
696        env_obj.insert(
697            "ANTHROPIC_BASE_URL".to_string(),
698            serde_json::Value::String(base),
699        );
700    }
701
702    let _ = std::fs::create_dir_all(&settings_dir);
703    let content = serde_json::to_string_pretty(&doc).unwrap_or_default();
704    let _ = std::fs::write(&settings_path, content + "\n");
705    if !quiet {
706        println!("  Configured ANTHROPIC_BASE_URL in Claude Code settings");
707    }
708}
709
710/// Proxy reachability timeout. Priority: env var > config.toml > 200ms default.
711pub fn proxy_timeout() -> std::time::Duration {
712    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS")
713        && let Ok(ms) = val.parse::<u64>()
714    {
715        return std::time::Duration::from_millis(ms);
716    }
717    if let Some(ms) = crate::core::config::Config::load().proxy_timeout_ms {
718        return std::time::Duration::from_millis(ms);
719    }
720    std::time::Duration::from_millis(200)
721}
722
723pub(crate) fn is_proxy_reachable(port: u16) -> bool {
724    use std::net::{IpAddr, Ipv4Addr, SocketAddr, TcpStream};
725    let addr = SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), port);
726    TcpStream::connect_timeout(&addr, proxy_timeout()).is_ok()
727}
728
729/// (Re)apply ONLY the Codex CLI proxy env from the current config — used by
730/// `proxy codex-chatgpt on|off` to write/strip Codex's `chatgpt_base_url`
731/// immediately after persisting the `[proxy] codex_chatgpt_proxy` opt-in, without
732/// touching Claude/Pi/shell exports. The opt-in is resolved from `config.toml`
733/// (env-independent), so this works for the env-less managed proxy and every
734/// later setup pass too (#603/#616).
735pub(crate) fn install_codex_env(home: &Path, port: u16, quiet: bool) {
736    let config_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
737    let mode = if codex_uses_chatgpt_login(home) {
738        CodexProxyMode::ChatGpt
739    } else {
740        CodexProxyMode::ApiKey
741    };
742    // The ChatGPT-subscription rail is opt-in (default off): routing it pins a
743    // `model_provider`, which scopes Codex history to that provider (#597), so we
744    // only write it when the user enabled `[proxy] codex_chatgpt_proxy`. Resolved
745    // from config.toml (env-independent) so the env-less managed proxy honors it.
746    let chatgpt_proxy = crate::core::config::Config::load()
747        .proxy
748        .codex_chatgpt_proxy_enabled();
749    install_codex_env_at_mode(&config_dir, port, quiet, mode, chatgpt_proxy);
750}
751
752#[derive(Debug, Clone, Copy, PartialEq, Eq)]
753enum CodexProxyMode {
754    ApiKey,
755    ChatGpt,
756}
757
758const CODEX_CHATGPT_PROVIDER_ID: &str = "leanctx-chatgpt";
759
760/// Testable core of `install_codex_env`: operates on an explicit Codex config
761/// directory instead of resolving it from `CODEX_HOME` / the real home.
762#[cfg(test)]
763fn install_codex_env_at(config_dir: &Path, port: u16, quiet: bool) {
764    install_codex_env_at_mode(config_dir, port, quiet, CodexProxyMode::ApiKey, false);
765}
766
767fn install_codex_env_at_mode(
768    config_dir: &Path,
769    port: u16,
770    quiet: bool,
771    mode: CodexProxyMode,
772    chatgpt_proxy: bool,
773) {
774    // API-key Codex is billed per token, so routing it through the proxy's `/v1`
775    // rail is where compression actually saves money. Codex reads the built-in
776    // OpenAI provider's base URL from the top-level `openai_base_url` key
777    // (openai/codex#12031).
778    //
779    // A ChatGPT *subscription* login is flat-rate, so the safe default writes
780    // NOTHING and leaves Codex talking directly to chatgpt.com (#597) — an empty
781    // `entries` still lets `render_codex_config` auto-heal stale lean-ctx entries.
782    //
783    // The opt-in `[proxy] codex_chatgpt_proxy` routes a ChatGPT subscription
784    // through the proxy for compression: it pins the generated `leanctx-chatgpt`
785    // provider (model turns → `/backend-api/codex/responses`, where the proxy
786    // strips the responses-lite marker so every model incl. gpt-5.5 works) and
787    // sets `chatgpt_base_url`. Pinning a provider scopes Codex history to it
788    // (#597), so it stays opt-in; flipping it back off strips the entries and
789    // restores native history + cloud/remote.
790    let base = format!("http://127.0.0.1:{port}");
791    let entries: Vec<(&str, String)> = match mode {
792        CodexProxyMode::ApiKey => vec![("openai_base_url", format!("{base}/v1"))],
793        CodexProxyMode::ChatGpt if chatgpt_proxy => vec![
794            ("model_provider", CODEX_CHATGPT_PROVIDER_ID.to_string()),
795            ("chatgpt_base_url", format!("{base}/backend-api/")),
796        ],
797        CodexProxyMode::ChatGpt => Vec::new(),
798    };
799    let provider_block = match mode {
800        CodexProxyMode::ChatGpt if chatgpt_proxy => {
801            Some(render_codex_chatgpt_provider_block(&base))
802        }
803        _ => None,
804    };
805
806    // Writing a proxy URL only makes sense against a live proxy.
807    if !entries.is_empty() && !is_proxy_reachable(port) {
808        if !quiet {
809            println!("  Skipping Codex CLI proxy env (proxy not running on port {port})");
810        }
811        return;
812    }
813
814    if !config_dir.exists() {
815        return;
816    }
817
818    let config_path = config_dir.join("config.toml");
819    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
820    let updated = render_codex_config(&existing, &entries, provider_block.as_deref());
821
822    if updated == existing {
823        if !quiet {
824            // `entries` is empty only for the safe ChatGPT-native default; any
825            // written rail (API-key `/v1` or the opt-in ChatGPT provider) means
826            // the proxy env is already in place.
827            if entries.is_empty() {
828                println!("  Codex ChatGPT login — config left native (no lean-ctx proxy entries)");
829            } else {
830                println!("  Codex CLI proxy env already configured");
831            }
832        }
833        return;
834    }
835
836    let _ = std::fs::write(&config_path, &updated);
837    if !quiet {
838        match mode {
839            CodexProxyMode::ApiKey => {
840                println!("  Configured openai_base_url in Codex CLI config");
841            }
842            CodexProxyMode::ChatGpt if chatgpt_proxy => println!(
843                "  Configured ChatGPT subscription provider in Codex CLI config (model turns compressed; history scoped to lean-ctx provider while enabled)"
844            ),
845            CodexProxyMode::ChatGpt => println!(
846                "  Codex ChatGPT login — removed stale lean-ctx proxy entries (Codex now talks directly to ChatGPT)"
847            ),
848        }
849    }
850}
851
852/// Point Codex's built-in OpenAI provider at `value` via the documented top-level
853/// `openai_base_url`/`chatgpt_base_url` keys. Removes lean-ctx's legacy local proxy
854/// entries — the dead `[env] OPENAI_BASE_URL` (#554) and the pre-#597
855/// `model_provider = leanctx-chatgpt` + `[model_providers.leanctx-chatgpt]` block
856/// (which hid Codex history) — and migrates a stale local value to the canonical
857/// one. A custom *remote* `openai_base_url` the user configured is preserved and
858/// never overwritten in API-key mode (#366). Keys are emitted as top-level keys
859/// (before the first `[table]`) so Codex actually reads them.
860fn render_codex_config(
861    existing: &str,
862    entries: &[(&str, String)],
863    append_block: Option<&str>,
864) -> String {
865    let mut cleaned = strip_codex_proxy_entries(existing);
866    if entries.iter().any(|(key, _)| *key == "model_provider") {
867        cleaned = strip_top_level_codex_config_key(&cleaned, "model_provider");
868        cleaned = strip_top_level_codex_config_key(&cleaned, "chatgpt_base_url");
869    }
870
871    let mut prefix = String::new();
872    for (key, value) in entries {
873        let has_remote_key = has_top_level_codex_config_key(&cleaned, key, |t| {
874            !(t.contains("127.0.0.1") || t.contains("localhost"))
875        });
876        if !has_remote_key {
877            prefix.push_str(&format!("{key} = \"{value}\"\n"));
878        }
879    }
880    let mut rendered = if prefix.is_empty() {
881        cleaned
882    } else {
883        // `strip_codex_proxy_entries` already dropped local keys, so prepend fresh
884        // top-level keys ahead of every existing line.
885        format!("{prefix}{cleaned}")
886    };
887    if let Some(block) = append_block {
888        if !rendered.is_empty() && !rendered.ends_with("\n\n") {
889            rendered.push('\n');
890        }
891        rendered.push_str(block);
892    }
893    rendered
894}
895
896fn render_codex_chatgpt_provider_block(base: &str) -> String {
897    format!(
898        "[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]\n\
899         name = \"OpenAI\"\n\
900         base_url = \"{base}/backend-api/codex\"\n\
901         requires_openai_auth = true\n\
902         supports_websockets = false\n"
903    )
904}
905
906fn strip_top_level_codex_config_key(body: &str, key: &str) -> String {
907    let mut out = Vec::new();
908    let mut in_top_level = true;
909    for line in body.lines() {
910        let t = line.trim_start();
911        if t.starts_with('[') {
912            in_top_level = false;
913        }
914        if in_top_level && toml_assignment_key(t) == Some(key) {
915            continue;
916        }
917        out.push(line);
918    }
919    let s = out.join("\n");
920    if s.is_empty() { s } else { format!("{s}\n") }
921}
922
923/// Remove lean-ctx's own Codex proxy entries from a `config.toml` body: local
924/// top-level proxy URLs, older dead `[env]` URL lines (#554), and the generated
925/// ChatGPT provider block. Custom remote endpoints and profile tables are preserved.
926fn strip_codex_proxy_entries(body: &str) -> String {
927    let lines: Vec<&str> = body.lines().collect();
928    let mut kept: Vec<&str> = Vec::with_capacity(lines.len());
929    let mut current_table: Option<&str> = None;
930    let mut i = 0;
931    while i < lines.len() {
932        let trimmed = lines[i].trim();
933        if is_generated_codex_chatgpt_provider_header(trimmed) {
934            i += 1;
935            while i < lines.len() && !lines[i].trim_start().starts_with('[') {
936                i += 1;
937            }
938            continue;
939        }
940
941        if lines[i].trim_start().starts_with('[') {
942            current_table = Some(trimmed);
943            kept.push(lines[i]);
944            i += 1;
945            continue;
946        }
947
948        if should_strip_codex_proxy_entry(lines[i].trim_start(), current_table) {
949            i += 1;
950            continue;
951        }
952
953        kept.push(lines[i]);
954        i += 1;
955    }
956
957    // Drop an `[env]` header left without any keys after the removal.
958    let mut out: Vec<&str> = Vec::with_capacity(kept.len());
959    let mut i = 0;
960    while i < kept.len() {
961        let trimmed = kept[i].trim();
962        if trimmed == "[env]" {
963            let mut j = i + 1;
964            while j < kept.len() && kept[j].trim().is_empty() {
965                j += 1;
966            }
967            if j >= kept.len() || kept[j].trim_start().starts_with('[') {
968                i = j;
969                continue;
970            }
971        }
972        out.push(kept[i]);
973        i += 1;
974    }
975
976    let mut s = out.join("\n");
977    while s.contains("\n\n\n") {
978        s = s.replace("\n\n\n", "\n\n");
979    }
980    let s = s.trim_end_matches('\n');
981    if s.is_empty() {
982        String::new()
983    } else {
984        format!("{s}\n")
985    }
986}
987
988fn has_top_level_codex_config_key(body: &str, key: &str, predicate: impl Fn(&str) -> bool) -> bool {
989    for line in body.lines() {
990        let t = line.trim_start();
991        if t.starts_with('[') {
992            break;
993        }
994        if toml_assignment_key(t) == Some(key) && predicate(t) {
995            return true;
996        }
997    }
998    false
999}
1000
1001fn should_strip_codex_proxy_entry(t: &str, current_table: Option<&str>) -> bool {
1002    match current_table {
1003        None => {
1004            is_local_codex_base_url_entry(t, &["openai_base_url", "chatgpt_base_url"])
1005                || is_codex_proxy_model_provider_entry(t)
1006        }
1007        Some("[env]") => is_local_codex_base_url_entry(t, &["OPENAI_BASE_URL", "CHATGPT_BASE_URL"]),
1008        _ => false,
1009    }
1010}
1011
1012fn is_local_codex_base_url_entry(t: &str, keys: &[&str]) -> bool {
1013    toml_assignment_key(t).is_some_and(|key| keys.contains(&key))
1014        && (t.contains("127.0.0.1") || t.contains("localhost"))
1015}
1016
1017fn toml_assignment_key(t: &str) -> Option<&str> {
1018    let key = t.split_once('=')?.0.trim();
1019    if key.is_empty() || key.starts_with('#') {
1020        None
1021    } else {
1022        Some(key)
1023    }
1024}
1025
1026fn is_codex_proxy_model_provider_entry(t: &str) -> bool {
1027    is_toml_string_assignment(t, "model_provider", CODEX_CHATGPT_PROVIDER_ID)
1028        || is_toml_string_assignment(t, "model_provider", "openai")
1029}
1030
1031fn is_toml_string_assignment(t: &str, key: &str, value: &str) -> bool {
1032    let Some((lhs, rhs)) = t.split_once('=') else {
1033        return false;
1034    };
1035    if lhs.trim() != key {
1036        return false;
1037    }
1038    let rhs = rhs.split('#').next().unwrap_or(rhs);
1039    let normalized: String = rhs.chars().filter(|c| !c.is_whitespace()).collect();
1040    normalized == format!("\"{value}\"")
1041}
1042
1043fn is_generated_codex_chatgpt_provider_header(t: &str) -> bool {
1044    t == format!("[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]")
1045}
1046
1047fn codex_config_has_local_proxy_entry(body: &str) -> bool {
1048    let mut current_table: Option<&str> = None;
1049    for line in body.lines() {
1050        let t = line.trim_start();
1051        if is_generated_codex_chatgpt_provider_header(line.trim()) {
1052            return true;
1053        }
1054        if t.starts_with('[') {
1055            current_table = Some(line.trim());
1056            continue;
1057        }
1058        match current_table {
1059            None => {
1060                if is_local_codex_base_url_entry(t, &["openai_base_url", "chatgpt_base_url"])
1061                    || is_toml_string_assignment(t, "model_provider", CODEX_CHATGPT_PROVIDER_ID)
1062                {
1063                    return true;
1064                }
1065            }
1066            Some("[env]")
1067                if is_local_codex_base_url_entry(t, &["OPENAI_BASE_URL", "CHATGPT_BASE_URL"]) =>
1068            {
1069                return true;
1070            }
1071            _ => {}
1072        }
1073    }
1074    false
1075}
1076
1077/// True when Codex will authenticate via a **ChatGPT login** (OAuth) rather than
1078/// an API key. An explicit `OPENAI_API_KEY` in the environment opts into API-key
1079/// mode and overrides the stored login.
1080fn codex_uses_chatgpt_login(home: &Path) -> bool {
1081    if std::env::var("OPENAI_API_KEY").is_ok_and(|v| !v.trim().is_empty()) {
1082        return false;
1083    }
1084    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
1085    auth_is_chatgpt(&codex_dir)
1086}
1087
1088/// True when `<codex_dir>/auth.json` records a ChatGPT/backend auth mode.
1089/// False when the file is missing, unreadable, or in API-key mode.
1090fn auth_is_chatgpt(codex_dir: &Path) -> bool {
1091    let Ok(content) = std::fs::read_to_string(codex_dir.join("auth.json")) else {
1092        return false;
1093    };
1094    let Ok(doc) = serde_json::from_str::<serde_json::Value>(&content) else {
1095        return false;
1096    };
1097    let Some(mode) = doc.get("auth_mode").and_then(|v| v.as_str()) else {
1098        return false;
1099    };
1100    let normalized = mode
1101        .chars()
1102        .filter(char::is_ascii_alphanumeric)
1103        .collect::<String>()
1104        .to_ascii_lowercase();
1105    matches!(
1106        normalized.as_str(),
1107        "chatgpt" | "chatgptauthtokens" | "personalaccesstoken" | "agentidentity"
1108    )
1109}
1110
1111pub fn default_port() -> u16 {
1112    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT")
1113        && let Ok(port) = val.parse::<u16>()
1114    {
1115        return port;
1116    }
1117    let cfg = crate::core::config::Config::load();
1118    if let Some(port) = cfg.proxy_port {
1119        return port;
1120    }
1121    uid_based_port()
1122}
1123
1124/// Derives a deterministic port from the user's UID to avoid collisions
1125/// on multi-user systems. uid 1000 → 4444, uid 1001 → 4445, etc.
1126/// System accounts (uid < 1000) and root always get the base port 4444.
1127fn uid_based_port() -> u16 {
1128    #[cfg(unix)]
1129    {
1130        // SAFETY: `getuid` takes no arguments, always succeeds, and only reads
1131        // the calling process's real UID — no preconditions, no UB.
1132        let uid = unsafe { libc::getuid() } as u16;
1133        let offset = uid.saturating_sub(1000) % 1000;
1134        DEFAULT_PROXY_PORT + offset
1135    }
1136    #[cfg(not(unix))]
1137    {
1138        DEFAULT_PROXY_PORT
1139    }
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use super::*;
1145
1146    #[test]
1147    fn uid_port_first_regular_user() {
1148        // uid 1000 (first regular user on most Linux) → base port
1149        assert_eq!(DEFAULT_PROXY_PORT, 4444);
1150    }
1151
1152    #[test]
1153    fn uid_port_no_overflow() {
1154        // Ensure port stays in valid range even with high UIDs
1155        // uid 2999 → offset (2999-1000) % 1000 = 999 → port 5443
1156        let port = DEFAULT_PROXY_PORT + 999;
1157        assert_eq!(port, 5443);
1158        assert!(port < u16::MAX);
1159    }
1160
1161    #[test]
1162    fn uid_port_system_accounts_get_base() {
1163        // uid < 1000 → saturating_sub gives 0 → base port
1164        let uid: u16 = 500;
1165        let offset = uid.saturating_sub(1000) % 1000;
1166        assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
1167    }
1168
1169    #[test]
1170    fn proxy_timeout_default_200ms() {
1171        if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
1172            return;
1173        }
1174        assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
1175    }
1176
1177    #[test]
1178    fn proxy_timeout_is_non_zero() {
1179        let t = proxy_timeout();
1180        assert!(t.as_millis() > 0);
1181    }
1182
1183    #[test]
1184    fn is_proxy_reachable_returns_false_on_unused_port() {
1185        assert!(!is_proxy_reachable(19999));
1186    }
1187
1188    #[test]
1189    fn posix_block_contains_all_provider_env_vars() {
1190        let base = "http://127.0.0.1:4444";
1191        let block = format!(
1192            r#"{PROXY_ENV_START}
1193export ANTHROPIC_BASE_URL="{base}"
1194export OPENAI_BASE_URL="{base}/v1"
1195export GEMINI_API_BASE_URL="{base}"
1196{PROXY_ENV_END}"#
1197        );
1198        assert!(
1199            block.contains("ANTHROPIC_BASE_URL"),
1200            "shell exports must include ANTHROPIC_BASE_URL"
1201        );
1202        assert!(
1203            block.contains("OPENAI_BASE_URL"),
1204            "shell exports must include OPENAI_BASE_URL"
1205        );
1206        assert!(
1207            block.contains("GEMINI_API_BASE_URL"),
1208            "shell exports must include GEMINI_API_BASE_URL"
1209        );
1210    }
1211
1212    #[test]
1213    fn fish_block_contains_all_provider_env_vars() {
1214        let base = "http://127.0.0.1:4444";
1215        let block = format!(
1216            r#"{PROXY_ENV_START}
1217set -gx ANTHROPIC_BASE_URL "{base}"
1218set -gx OPENAI_BASE_URL "{base}/v1"
1219set -gx GEMINI_API_BASE_URL "{base}"
1220{PROXY_ENV_END}"#
1221        );
1222        assert!(block.contains("ANTHROPIC_BASE_URL"));
1223        assert!(block.contains("OPENAI_BASE_URL"));
1224        assert!(block.contains("GEMINI_API_BASE_URL"));
1225    }
1226
1227    #[test]
1228    fn powershell_block_contains_all_provider_env_vars() {
1229        let base = "http://127.0.0.1:4444";
1230        let block = format!(
1231            r#"{PROXY_ENV_START}
1232$env:ANTHROPIC_BASE_URL = "{base}"
1233$env:OPENAI_BASE_URL = "{base}/v1"
1234$env:GEMINI_API_BASE_URL = "{base}"
1235{PROXY_ENV_END}"#
1236        );
1237        assert!(block.contains("ANTHROPIC_BASE_URL"));
1238        assert!(block.contains("OPENAI_BASE_URL"));
1239        assert!(block.contains("GEMINI_API_BASE_URL"));
1240    }
1241
1242    /// The subscription guard reads the process environment; these tests are only
1243    /// meaningful when the test runner itself does not provide an Anthropic key.
1244    fn env_provides_anthropic_key() -> bool {
1245        std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty())
1246            || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok_and(|v| !v.trim().is_empty())
1247    }
1248
1249    /// `claude_state_dir` honours `CLAUDE_CONFIG_DIR`; when set it would escape the
1250    /// temp HOME and read the real settings file, so skip in that case.
1251    fn claude_dir_overridden() -> bool {
1252        std::env::var("CLAUDE_CONFIG_DIR").is_ok_and(|v| !v.trim().is_empty())
1253    }
1254
1255    fn write_claude_settings(home: &Path, json: &str) -> std::path::PathBuf {
1256        let dir = home.join(".claude");
1257        std::fs::create_dir_all(&dir).unwrap();
1258        let path = dir.join("settings.json");
1259        std::fs::write(&path, json).unwrap();
1260        path
1261    }
1262
1263    #[test]
1264    fn api_key_available_true_with_api_key_helper() {
1265        if claude_dir_overridden() {
1266            return;
1267        }
1268        let home = tempfile::tempdir().unwrap();
1269        write_claude_settings(home.path(), r#"{"apiKeyHelper": "echo sk-test"}"#);
1270        assert!(anthropic_api_key_available(home.path()));
1271    }
1272
1273    #[test]
1274    fn api_key_available_true_with_settings_env_key() {
1275        if claude_dir_overridden() {
1276            return;
1277        }
1278        let home = tempfile::tempdir().unwrap();
1279        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1280        assert!(anthropic_api_key_available(home.path()));
1281    }
1282
1283    #[test]
1284    fn api_key_available_false_without_key() {
1285        if env_provides_anthropic_key() || claude_dir_overridden() {
1286            return;
1287        }
1288        let home = tempfile::tempdir().unwrap();
1289        write_claude_settings(home.path(), r#"{"env": {}}"#);
1290        assert!(!anthropic_api_key_available(home.path()));
1291    }
1292
1293    #[test]
1294    fn api_key_available_false_when_no_settings_file() {
1295        if env_provides_anthropic_key() || claude_dir_overridden() {
1296            return;
1297        }
1298        let home = tempfile::tempdir().unwrap();
1299        assert!(!anthropic_api_key_available(home.path()));
1300    }
1301
1302    #[test]
1303    fn subscription_guard_skips_redirect_without_key() {
1304        if env_provides_anthropic_key() || claude_dir_overridden() {
1305            return;
1306        }
1307        let home = tempfile::tempdir().unwrap();
1308        // No settings file → subscription mode, empty current URL → nothing to repair.
1309        install_claude_env_inner(home.path(), 4444, true, false);
1310        let settings = home.path().join(".claude/settings.json");
1311        assert!(
1312            !settings.exists(),
1313            "subscription mode must not write a proxy redirect"
1314        );
1315    }
1316
1317    #[test]
1318    fn subscription_guard_repairs_stale_local_redirect() {
1319        if env_provides_anthropic_key() || claude_dir_overridden() {
1320            return;
1321        }
1322        let home = tempfile::tempdir().unwrap();
1323        let path = write_claude_settings(
1324            home.path(),
1325            r#"{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4444"}}"#,
1326        );
1327        install_claude_env_inner(home.path(), 4444, true, false);
1328        let after = std::fs::read_to_string(&path).unwrap();
1329        let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1330        let base = doc
1331            .get("env")
1332            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1333            .and_then(|v| v.as_str())
1334            .unwrap_or("");
1335        assert!(
1336            !is_local_lean_ctx_url(base),
1337            "stale local redirect must be repaired in subscription mode, got {base:?}"
1338        );
1339    }
1340
1341    /// API-key mode must STILL route Claude through the proxy (we only protect
1342    /// subscriptions; pay-as-you-go users keep their compression). Uses a real bound
1343    /// port so `is_proxy_reachable` passes, exercising the full production path.
1344    #[test]
1345    fn install_redirects_claude_when_api_key_present() {
1346        if claude_dir_overridden() {
1347            return;
1348        }
1349        let home = tempfile::tempdir().unwrap();
1350        // API-key mode declared in settings.json → deterministic regardless of env.
1351        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1352        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1353        let port = listener.local_addr().unwrap().port();
1354
1355        install_claude_env_inner(home.path(), port, true, false);
1356
1357        let after = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
1358        let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1359        let base = doc
1360            .get("env")
1361            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1362            .and_then(|v| v.as_str())
1363            .unwrap_or("");
1364        assert_eq!(
1365            base,
1366            format!("http://127.0.0.1:{port}"),
1367            "API-key mode must route Claude through the proxy"
1368        );
1369    }
1370
1371    /// Shell export: subscription mode keeps OpenAI/Gemini but omits the ANTHROPIC line
1372    /// (replaced by an explanatory comment), so a shell-launched Claude stays on
1373    /// api.anthropic.com.
1374    #[test]
1375    fn shell_export_omits_anthropic_without_key() {
1376        if env_provides_anthropic_key() || claude_dir_overridden() {
1377            return;
1378        }
1379        let home = tempfile::tempdir().unwrap();
1380        std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1381        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1382        let port = listener.local_addr().unwrap().port();
1383
1384        install_shell_exports(home.path(), port, true);
1385
1386        let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1387        assert!(
1388            rc.contains(&format!(
1389                "export OPENAI_BASE_URL=\"http://127.0.0.1:{port}/v1\""
1390            )),
1391            "OpenAI export must remain and carry the /v1 suffix (#366)"
1392        );
1393        assert!(
1394            rc.contains(&format!(
1395                "export GEMINI_API_BASE_URL=\"http://127.0.0.1:{port}\""
1396            )),
1397            "Gemini export must remain WITHOUT /v1 (SDK appends /v1beta itself)"
1398        );
1399        assert!(
1400            !rc.contains("export ANTHROPIC_BASE_URL="),
1401            "ANTHROPIC export must be omitted in subscription mode"
1402        );
1403        assert!(
1404            rc.contains(ANTHROPIC_OMITTED_NOTE),
1405            "omission must be explained in the RC block"
1406        );
1407    }
1408
1409    /// Codex CLI config: a fresh install writes the `/v1`-suffixed proxy URL (#366).
1410    #[test]
1411    fn codex_env_writes_v1_suffixed_url() {
1412        let dir = tempfile::tempdir().unwrap();
1413        let codex_dir = dir.path().join(".codex");
1414        std::fs::create_dir_all(&codex_dir).unwrap();
1415        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1416        let port = listener.local_addr().unwrap().port();
1417
1418        install_codex_env_at(&codex_dir, port, true);
1419
1420        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1421        assert!(
1422            cfg.contains(&format!("openai_base_url = \"http://127.0.0.1:{port}/v1\"")),
1423            "Codex config must set top-level openai_base_url with the /v1 suffix, got:\n{cfg}"
1424        );
1425        assert!(
1426            !cfg.contains("[env]") && !cfg.contains("OPENAI_BASE_URL"),
1427            "must not write the dead [env] OPENAI_BASE_URL form (#554), got:\n{cfg}"
1428        );
1429        assert!(
1430            !cfg.contains(CODEX_CHATGPT_PROVIDER_ID),
1431            "API-key mode must not install the ChatGPT-only provider, got:\n{cfg}"
1432        );
1433        assert!(
1434            !cfg.contains("chatgpt_base_url"),
1435            "API-key mode must not install the ChatGPT backend rail, got:\n{cfg}"
1436        );
1437    }
1438
1439    /// Codex CLI config: a legacy `[env] OPENAI_BASE_URL` line (which Codex never
1440    /// read, #554) is removed and replaced by a top-level `openai_base_url`, even
1441    /// when stale (missing `/v1`). The dead `[env]` table is collapsed.
1442    #[test]
1443    fn codex_env_migrates_legacy_env_entry() {
1444        let dir = tempfile::tempdir().unwrap();
1445        let codex_dir = dir.path().join(".codex");
1446        std::fs::create_dir_all(&codex_dir).unwrap();
1447        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1448        let port = listener.local_addr().unwrap().port();
1449        std::fs::write(
1450            codex_dir.join("config.toml"),
1451            format!(
1452                "model = \"gpt-5.2\"\n\n[env]\nOPENAI_BASE_URL = \"http://127.0.0.1:{port}\"\n"
1453            ),
1454        )
1455        .unwrap();
1456
1457        install_codex_env_at(&codex_dir, port, true);
1458
1459        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1460        assert!(
1461            cfg.contains(&format!("openai_base_url = \"http://127.0.0.1:{port}/v1\"")),
1462            "legacy entry must become a top-level openai_base_url (/v1), got:\n{cfg}"
1463        );
1464        assert!(
1465            cfg.contains("model = \"gpt-5.2\""),
1466            "unrelated config must be preserved"
1467        );
1468        assert!(
1469            !cfg.contains("OPENAI_BASE_URL"),
1470            "dead legacy [env] OPENAI_BASE_URL must be removed, got:\n{cfg}"
1471        );
1472        assert!(
1473            !cfg.contains("[env]"),
1474            "empty [env] table must be collapsed, got:\n{cfg}"
1475        );
1476    }
1477
1478    /// Codex CLI config: a custom non-local `openai_base_url` is never rewritten.
1479    #[test]
1480    fn codex_env_preserves_custom_remote_endpoint() {
1481        let dir = tempfile::tempdir().unwrap();
1482        let codex_dir = dir.path().join(".codex");
1483        std::fs::create_dir_all(&codex_dir).unwrap();
1484        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1485        let port = listener.local_addr().unwrap().port();
1486        let original = "openai_base_url = \"https://my-gateway.example.com/v1\"\n";
1487        std::fs::write(codex_dir.join("config.toml"), original).unwrap();
1488
1489        install_codex_env_at(&codex_dir, port, true);
1490
1491        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1492        assert!(
1493            cfg.contains("https://my-gateway.example.com/v1"),
1494            "custom remote endpoint must be preserved, got:\n{cfg}"
1495        );
1496        assert!(
1497            !cfg.contains("127.0.0.1"),
1498            "proxy URL must not be injected over a custom endpoint"
1499        );
1500    }
1501
1502    #[test]
1503    fn codex_env_chatgpt_mode_writes_subscription_provider() {
1504        let dir = tempfile::tempdir().unwrap();
1505        let codex_dir = dir.path().join(".codex");
1506        std::fs::create_dir_all(&codex_dir).unwrap();
1507        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1508        let port = listener.local_addr().unwrap().port();
1509        std::fs::write(
1510            codex_dir.join("config.toml"),
1511            "model_provider = \"custom\"\nchatgpt_base_url = \"https://chatgpt.example.com/backend-api/\"\nmodel = \"gpt-5.5\"\n",
1512        )
1513        .unwrap();
1514
1515        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1516
1517        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1518        assert!(
1519            !cfg.contains("openai_base_url"),
1520            "ChatGPT mode must not write a proxy openai_base_url, got:\n{cfg}"
1521        );
1522        assert!(
1523            cfg.contains(&format!("model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"")),
1524            "ChatGPT mode must select the lean-ctx ChatGPT provider, got:\n{cfg}"
1525        );
1526        assert!(
1527            !cfg.contains("model_provider = \"custom\""),
1528            "ChatGPT mode must replace stale top-level model_provider, got:\n{cfg}"
1529        );
1530        assert!(
1531            cfg.contains(&format!(
1532                "chatgpt_base_url = \"http://127.0.0.1:{port}/backend-api/\""
1533            )),
1534            "ChatGPT mode must write the backend rail, got:\n{cfg}"
1535        );
1536        assert!(
1537            !cfg.contains("https://chatgpt.example.com"),
1538            "ChatGPT mode must replace stale top-level chatgpt_base_url, got:\n{cfg}"
1539        );
1540        assert!(
1541            cfg.contains(&format!("[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]")),
1542            "ChatGPT mode must install the generated provider block, got:\n{cfg}"
1543        );
1544        assert!(
1545            cfg.contains(&format!(
1546                "base_url = \"http://127.0.0.1:{port}/backend-api/codex\""
1547            )),
1548            "ChatGPT provider must target the Codex backend rail, got:\n{cfg}"
1549        );
1550        assert!(
1551            cfg.contains("model = \"gpt-5.5\""),
1552            "user keys are preserved, got:\n{cfg}"
1553        );
1554    }
1555
1556    /// #597-safe default: a ChatGPT login with the opt-in OFF must leave Codex
1557    /// native — no `model_provider` pin (which would scope/hide history), no
1558    /// `chatgpt_base_url`, no provider block, no proxy URL at all.
1559    #[test]
1560    fn codex_env_chatgpt_mode_optout_writes_nothing() {
1561        let dir = tempfile::tempdir().unwrap();
1562        let codex_dir = dir.path().join(".codex");
1563        std::fs::create_dir_all(&codex_dir).unwrap();
1564        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1565        let port = listener.local_addr().unwrap().port();
1566        std::fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap();
1567
1568        // Opt-in OFF (chatgpt_proxy = false).
1569        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, false);
1570
1571        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1572        assert!(
1573            !cfg.contains("model_provider"),
1574            "opt-out must not pin a model_provider (#597), got:\n{cfg}"
1575        );
1576        assert!(
1577            !cfg.contains("chatgpt_base_url") && !cfg.contains("openai_base_url"),
1578            "opt-out must not write any proxy base URL, got:\n{cfg}"
1579        );
1580        assert!(
1581            !cfg.contains(CODEX_CHATGPT_PROVIDER_ID) && !cfg.contains("127.0.0.1"),
1582            "opt-out must not install the provider block or any proxy URL, got:\n{cfg}"
1583        );
1584        assert!(cfg.contains("model = \"gpt-5.5\""), "user keys preserved");
1585    }
1586
1587    /// Flipping the opt-in OFF after it was ON strips the provider config back to
1588    /// native, so Codex history + cloud/remote return (#597).
1589    #[test]
1590    fn codex_env_chatgpt_optin_toggle_off_restores_native() {
1591        let dir = tempfile::tempdir().unwrap();
1592        let codex_dir = dir.path().join(".codex");
1593        std::fs::create_dir_all(&codex_dir).unwrap();
1594        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1595        let port = listener.local_addr().unwrap().port();
1596        std::fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap();
1597
1598        // ON → provider config present.
1599        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1600        let on = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1601        assert!(
1602            on.contains(CODEX_CHATGPT_PROVIDER_ID),
1603            "opt-in writes provider"
1604        );
1605
1606        // OFF → stripped back to native.
1607        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, false);
1608        let off = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1609        assert!(
1610            !off.contains("model_provider")
1611                && !off.contains("chatgpt_base_url")
1612                && !off.contains(CODEX_CHATGPT_PROVIDER_ID)
1613                && !off.contains("127.0.0.1"),
1614            "toggling opt-in off restores native config, got:\n{off}"
1615        );
1616        assert!(off.contains("model = \"gpt-5.5\""), "user keys preserved");
1617    }
1618
1619    /// With the opt-in enabled, ChatGPT subscription mode writes the provider
1620    /// config. Also covers idempotency and API-key toggle cleanup.
1621    #[test]
1622    fn codex_env_chatgpt_mode_writes_backend_url_idempotently() {
1623        let dir = tempfile::tempdir().unwrap();
1624        let codex_dir = dir.path().join(".codex");
1625        std::fs::create_dir_all(&codex_dir).unwrap();
1626        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1627        let port = listener.local_addr().unwrap().port();
1628        std::fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap();
1629
1630        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1631
1632        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1633        assert!(
1634            cfg.contains(&format!("model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"")),
1635            "ChatGPT mode must pin the lean-ctx provider, got:\n{cfg}"
1636        );
1637        assert!(
1638            cfg.contains(&format!(
1639                "chatgpt_base_url = \"http://127.0.0.1:{port}/backend-api/\""
1640            )),
1641            "ChatGPT mode must point chatgpt_base_url at the proxy backend-api rail, got:\n{cfg}"
1642        );
1643        assert!(
1644            !cfg.contains("openai_base_url"),
1645            "ChatGPT mode routes via chatgpt_base_url, not the /v1 openai_base_url, got:\n{cfg}"
1646        );
1647        assert!(
1648            cfg.contains("model = \"gpt-5.5\""),
1649            "user keys are preserved, got:\n{cfg}"
1650        );
1651
1652        // Idempotent: a second run yields the identical body ("already configured").
1653        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1654        let again = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1655        assert_eq!(cfg, again, "opt-in render must be idempotent");
1656
1657        // Switching to API-key mode strips the ChatGPT-only rail.
1658        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ApiKey, false);
1659        let off = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1660        assert!(
1661            !off.contains("chatgpt_base_url") && !off.contains(CODEX_CHATGPT_PROVIDER_ID),
1662            "API-key mode must remove ChatGPT-only config, got:\n{off}"
1663        );
1664        assert!(off.contains(&format!("openai_base_url = \"http://127.0.0.1:{port}/v1\"")));
1665        assert!(off.contains("model = \"gpt-5.5\""));
1666    }
1667
1668    /// Upgrade over old ChatGPT-proxy entries strips stale values first, then
1669    /// writes the current ChatGPT subscription provider config.
1670    #[test]
1671    fn codex_chatgpt_upgrade_strips_legacy_leanctx_provider() {
1672        let dir = tempfile::tempdir().unwrap();
1673        let codex_dir = dir.path().join(".codex");
1674        std::fs::create_dir_all(&codex_dir).unwrap();
1675        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1676        let port = listener.local_addr().unwrap().port();
1677        // Realistic legacy layout: lean-ctx prepended its keys at the top and
1678        // appended the provider block last, so user content sat in between.
1679        let legacy = format!(
1680            "model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"\n\
1681             openai_base_url = \"http://127.0.0.1:{port}/backend-api/codex\"\n\
1682             chatgpt_base_url = \"http://127.0.0.1:{port}/backend-api\"\n\
1683             model = \"gpt-5.5\"\n\n\
1684             {LEGACY_CHATGPT_PROVIDER_BLOCK}"
1685        );
1686        std::fs::write(codex_dir.join("config.toml"), legacy).unwrap();
1687
1688        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1689
1690        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1691        assert!(
1692            !cfg.contains("openai_base_url"),
1693            "backend-api openai_base_url override must be removed (breaks remote), got:\n{cfg}"
1694        );
1695        assert!(
1696            cfg.contains(&format!("model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"")),
1697            "current ChatGPT provider must be written, got:\n{cfg}"
1698        );
1699        assert!(
1700            cfg.contains(&format!(
1701                "chatgpt_base_url = \"http://127.0.0.1:{port}/backend-api/\""
1702            )),
1703            "current ChatGPT backend URL must be written, got:\n{cfg}"
1704        );
1705        assert!(cfg.contains("model = \"gpt-5.5\""));
1706    }
1707
1708    /// `render_codex_config` is idempotent: applying it to an already-configured
1709    /// body yields the identical body (so `install` reports "already configured").
1710    #[test]
1711    fn render_codex_config_is_idempotent() {
1712        let entries = vec![("openai_base_url", "http://127.0.0.1:4444/v1".to_string())];
1713        let once = render_codex_config("model = \"gpt-5.5\"\n", &entries, None);
1714        let twice = render_codex_config(&once, &entries, None);
1715        assert_eq!(once, twice, "render must be idempotent");
1716        assert!(once.starts_with("openai_base_url = \"http://127.0.0.1:4444/v1\"\n"));
1717        assert!(once.contains("model = \"gpt-5.5\""));
1718    }
1719
1720    /// The `[model_providers.leanctx-chatgpt]` block lean-ctx wrote before #597.
1721    /// Kept verbatim here so the strip/auto-heal tests exercise a real legacy body
1722    /// even though the renderer no longer produces it.
1723    const LEGACY_CHATGPT_PROVIDER_BLOCK: &str = "[model_providers.leanctx-chatgpt]\n\
1724         name = \"OpenAI\"\n\
1725         base_url = \"http://127.0.0.1:4444/backend-api/codex\"\n\
1726         requires_openai_auth = true\n\
1727         supports_websockets = false\n";
1728
1729    #[test]
1730    fn strip_codex_proxy_entries_preserves_nested_model_provider() {
1731        let body = format!(
1732            "model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"\n\
1733             openai_base_url = \"http://127.0.0.1:4444/backend-api/codex\"\n\
1734             chatgpt_base_url = \"http://127.0.0.1:4444/backend-api\"\n\n\
1735             {LEGACY_CHATGPT_PROVIDER_BLOCK}\n\
1736             [profiles.work]\n\
1737             model_provider = \"openai\"\n\
1738             openai_base_url = \"http://127.0.0.1:9999/v1\"\n"
1739        );
1740
1741        let out = strip_codex_proxy_entries(&body);
1742
1743        assert!(
1744            !out.contains(&format!("[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]")),
1745            "generated provider block must be removed, got:\n{out}"
1746        );
1747        assert!(
1748            out.contains(
1749                "[profiles.work]\nmodel_provider = \"openai\"\nopenai_base_url = \"http://127.0.0.1:9999/v1\""
1750            ),
1751            "profile provider config must be preserved, got:\n{out}"
1752        );
1753    }
1754
1755    #[test]
1756    fn codex_proxy_cleanup_detection_ignores_plain_openai_provider() {
1757        assert!(!codex_config_has_local_proxy_entry(
1758            "model_provider = \"openai\"\n"
1759        ));
1760        assert!(codex_config_has_local_proxy_entry(&format!(
1761            "model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"\n"
1762        )));
1763    }
1764
1765    /// `render_codex_config` inserts the key as a *top-level* key (before the first
1766    /// `[table]`), otherwise Codex would read it as a sub-key and ignore it.
1767    #[test]
1768    fn render_codex_config_inserts_before_first_table() {
1769        let body = "model = \"gpt-5.5\"\n\n[features]\nhooks = true\n";
1770        let entries = vec![("openai_base_url", "http://127.0.0.1:4444/v1".to_string())];
1771        let out = render_codex_config(body, &entries, None);
1772        let key_idx = out.find("openai_base_url").expect("key present");
1773        let table_idx = out.find("[features]").expect("table present");
1774        assert!(
1775            key_idx < table_idx,
1776            "openai_base_url must precede the first table, got:\n{out}"
1777        );
1778    }
1779
1780    /// `auth_is_chatgpt` reflects Codex's `auth.json` auth mode.
1781    #[test]
1782    fn auth_is_chatgpt_detects_login_mode() {
1783        let dir = tempfile::tempdir().unwrap();
1784        let codex_dir = dir.path().join(".codex");
1785        std::fs::create_dir_all(&codex_dir).unwrap();
1786
1787        assert!(!auth_is_chatgpt(&codex_dir), "no auth.json => not chatgpt");
1788
1789        std::fs::write(
1790            codex_dir.join("auth.json"),
1791            r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-test"}"#,
1792        )
1793        .unwrap();
1794        assert!(!auth_is_chatgpt(&codex_dir), "apikey mode => not chatgpt");
1795
1796        std::fs::write(
1797            codex_dir.join("auth.json"),
1798            r#"{"auth_mode":"chatgpt","tokens":{"access_token":"x"}}"#,
1799        )
1800        .unwrap();
1801        assert!(auth_is_chatgpt(&codex_dir), "chatgpt mode => true");
1802
1803        for mode in ["chatgptAuthTokens", "personalAccessToken", "agentIdentity"] {
1804            std::fs::write(
1805                codex_dir.join("auth.json"),
1806                format!(r#"{{"auth_mode":"{mode}","tokens":{{"access_token":"x"}}}}"#),
1807            )
1808            .unwrap();
1809            assert!(auth_is_chatgpt(&codex_dir), "{mode} => true");
1810        }
1811    }
1812
1813    /// Shell export: API-key mode includes the ANTHROPIC export (symmetry check).
1814    #[test]
1815    fn shell_export_includes_anthropic_with_key() {
1816        if claude_dir_overridden() {
1817            return;
1818        }
1819        let home = tempfile::tempdir().unwrap();
1820        std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1821        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1822        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1823        let port = listener.local_addr().unwrap().port();
1824
1825        install_shell_exports(home.path(), port, true);
1826
1827        let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1828        assert!(
1829            rc.contains(&format!(
1830                "export ANTHROPIC_BASE_URL=\"http://127.0.0.1:{port}\""
1831            )),
1832            "API-key mode must export ANTHROPIC_BASE_URL"
1833        );
1834    }
1835
1836    fn read_pi_models(agent_dir: &Path) -> serde_json::Value {
1837        let raw = std::fs::read_to_string(agent_dir.join("models.json")).unwrap();
1838        crate::core::jsonc::parse_jsonc(&raw).unwrap()
1839    }
1840
1841    /// #361: `proxy enable` must reach Pi/forge, which read `providers.*.baseUrl`
1842    /// from models.json (not ANTHROPIC_BASE_URL). Fresh install wires both
1843    /// providers with the per-SDK URL convention (anthropic bare, openai `/v1`).
1844    #[test]
1845    fn pi_env_fresh_install_writes_both_providers() {
1846        let dir = tempfile::tempdir().unwrap();
1847        let agent_dir = dir.path().join(".pi/agent");
1848        std::fs::create_dir_all(&agent_dir).unwrap();
1849        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1850        let port = listener.local_addr().unwrap().port();
1851
1852        install_pi_env_at(&agent_dir, port, true, false);
1853
1854        let doc = read_pi_models(&agent_dir);
1855        assert_eq!(
1856            pi_provider_base_url(&doc, "anthropic"),
1857            format!("http://127.0.0.1:{port}"),
1858            "Anthropic gets the bare origin (SDK appends /v1 itself)"
1859        );
1860        assert_eq!(
1861            pi_provider_base_url(&doc, "openai"),
1862            format!("http://127.0.0.1:{port}/v1"),
1863            "OpenAI gets the /v1-suffixed URL (#366)"
1864        );
1865    }
1866
1867    /// A user's custom remote gateway must survive `proxy enable` (no --force):
1868    /// only the untouched provider is pointed at the proxy.
1869    #[test]
1870    fn pi_env_preserves_custom_remote_endpoint_without_force() {
1871        let dir = tempfile::tempdir().unwrap();
1872        let agent_dir = dir.path().join(".pi/agent");
1873        std::fs::create_dir_all(&agent_dir).unwrap();
1874        std::fs::write(
1875            agent_dir.join("models.json"),
1876            r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1877        )
1878        .unwrap();
1879        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1880        let port = listener.local_addr().unwrap().port();
1881
1882        install_pi_env_at(&agent_dir, port, true, false);
1883
1884        let doc = read_pi_models(&agent_dir);
1885        assert_eq!(
1886            pi_provider_base_url(&doc, "anthropic"),
1887            "https://gw.example.com",
1888            "custom remote endpoint must be preserved without --force"
1889        );
1890        assert_eq!(
1891            pi_provider_base_url(&doc, "openai"),
1892            format!("http://127.0.0.1:{port}/v1"),
1893            "the untouched provider still gets the proxy"
1894        );
1895    }
1896
1897    /// `--force` (the `proxy enable --force` path) overrides a custom endpoint.
1898    #[test]
1899    fn pi_env_force_overrides_custom_endpoint() {
1900        let dir = tempfile::tempdir().unwrap();
1901        let agent_dir = dir.path().join(".pi/agent");
1902        std::fs::create_dir_all(&agent_dir).unwrap();
1903        std::fs::write(
1904            agent_dir.join("models.json"),
1905            r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1906        )
1907        .unwrap();
1908        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1909        let port = listener.local_addr().unwrap().port();
1910
1911        install_pi_env_at(&agent_dir, port, true, true);
1912
1913        let doc = read_pi_models(&agent_dir);
1914        assert_eq!(
1915            pi_provider_base_url(&doc, "anthropic"),
1916            format!("http://127.0.0.1:{port}"),
1917            "--force must override the custom endpoint"
1918        );
1919    }
1920
1921    /// A user without Pi installed must not get a Pi config materialized.
1922    #[test]
1923    fn pi_env_skips_when_agent_dir_absent() {
1924        let dir = tempfile::tempdir().unwrap();
1925        let agent_dir = dir.path().join(".pi/agent");
1926
1927        install_pi_env_at(&agent_dir, 19999, true, false);
1928
1929        assert!(
1930            !agent_dir.join("models.json").exists(),
1931            "no Pi config must be created when Pi is not configured"
1932        );
1933    }
1934
1935    /// `disable` reverts only the providers pointing at the local proxy; a
1936    /// user-owned custom endpoint is left untouched.
1937    #[test]
1938    fn pi_uninstall_removes_only_local_endpoints() {
1939        let dir = tempfile::tempdir().unwrap();
1940        let agent_dir = dir.path().join(".pi/agent");
1941        std::fs::create_dir_all(&agent_dir).unwrap();
1942        std::fs::write(
1943            agent_dir.join("models.json"),
1944            r#"{"providers":{"anthropic":{"baseUrl":"http://127.0.0.1:4444"},"openai":{"baseUrl":"https://api.openai.com/v1"}}}"#,
1945        )
1946        .unwrap();
1947
1948        uninstall_pi_env_at(&agent_dir, true);
1949
1950        let doc = read_pi_models(&agent_dir);
1951        assert_eq!(
1952            pi_provider_base_url(&doc, "anthropic"),
1953            "",
1954            "the local proxy endpoint we set must be removed"
1955        );
1956        assert_eq!(
1957            pi_provider_base_url(&doc, "openai"),
1958            "https://api.openai.com/v1",
1959            "a custom endpoint must be preserved on disable"
1960        );
1961    }
1962}