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 only ChatGPT subscription
784    // model turns through the generated `leanctx-chatgpt` provider
785    // (`/backend-api/codex/responses`, where the proxy strips the responses-lite
786    // marker so every model incl. gpt-5.5 works). Keep `chatgpt_base_url` native:
787    // Codex Apps MCP and other ChatGPT aux rails require first-party ChatGPT
788    // request cookies/headers (otherwise upstream returns
789    // `no_biscuit_no_service`), and model-turn compression does not need those
790    // rails. Pinning a provider scopes Codex history (#597), so it stays opt-in;
791    // flipping it back off strips the entries and restores native history.
792    let base = format!("http://127.0.0.1:{port}");
793    let entries: Vec<(&str, String)> = match mode {
794        CodexProxyMode::ApiKey => vec![("openai_base_url", format!("{base}/v1"))],
795        CodexProxyMode::ChatGpt if chatgpt_proxy => {
796            vec![("model_provider", CODEX_CHATGPT_PROVIDER_ID.to_string())]
797        }
798        CodexProxyMode::ChatGpt => Vec::new(),
799    };
800    let provider_block = match mode {
801        CodexProxyMode::ChatGpt if chatgpt_proxy => {
802            Some(render_codex_chatgpt_provider_block(&base))
803        }
804        _ => None,
805    };
806
807    // Writing a proxy URL only makes sense against a live proxy.
808    if !entries.is_empty() && !is_proxy_reachable(port) {
809        if !quiet {
810            println!("  Skipping Codex CLI proxy env (proxy not running on port {port})");
811        }
812        return;
813    }
814
815    if !config_dir.exists() {
816        return;
817    }
818
819    let config_path = config_dir.join("config.toml");
820    let existing = std::fs::read_to_string(&config_path).unwrap_or_default();
821    let updated = render_codex_config(&existing, &entries, provider_block.as_deref());
822
823    if updated == existing {
824        if !quiet {
825            // `entries` is empty only for the safe ChatGPT-native default; any
826            // written rail (API-key `/v1` or the opt-in ChatGPT provider) means
827            // the proxy env is already in place.
828            if entries.is_empty() {
829                println!("  Codex ChatGPT login — config left native (no lean-ctx proxy entries)");
830            } else {
831                println!("  Codex CLI proxy env already configured");
832            }
833        }
834        return;
835    }
836
837    let _ = std::fs::write(&config_path, &updated);
838    if !quiet {
839        match mode {
840            CodexProxyMode::ApiKey => {
841                println!("  Configured openai_base_url in Codex CLI config");
842            }
843            CodexProxyMode::ChatGpt if chatgpt_proxy => println!(
844                "  Configured ChatGPT subscription provider in Codex CLI config (model turns compressed; history scoped to lean-ctx provider while enabled)"
845            ),
846            CodexProxyMode::ChatGpt => println!(
847                "  Codex ChatGPT login — removed stale lean-ctx proxy entries (Codex now talks directly to ChatGPT)"
848            ),
849        }
850    }
851}
852
853/// Point Codex's built-in OpenAI provider at `value` via the documented top-level
854/// `openai_base_url`/`chatgpt_base_url` keys. Removes lean-ctx's legacy local proxy
855/// entries — the dead `[env] OPENAI_BASE_URL` (#554) and the pre-#597
856/// `model_provider = leanctx-chatgpt` + `[model_providers.leanctx-chatgpt]` block
857/// (which hid Codex history) — and migrates a stale local value to the canonical
858/// one. A custom *remote* `openai_base_url` the user configured is preserved and
859/// never overwritten in API-key mode (#366). Keys are emitted as top-level keys
860/// (before the first `[table]`) so Codex actually reads them.
861fn render_codex_config(
862    existing: &str,
863    entries: &[(&str, String)],
864    append_block: Option<&str>,
865) -> String {
866    let mut cleaned = strip_codex_proxy_entries(existing);
867    if entries.iter().any(|(key, _)| *key == "model_provider") {
868        cleaned = strip_top_level_codex_config_key(&cleaned, "model_provider");
869        cleaned = strip_top_level_codex_config_key(&cleaned, "chatgpt_base_url");
870    }
871
872    let mut prefix = String::new();
873    for (key, value) in entries {
874        let has_remote_key = has_top_level_codex_config_key(&cleaned, key, |t| {
875            !(t.contains("127.0.0.1") || t.contains("localhost"))
876        });
877        if !has_remote_key {
878            prefix.push_str(&format!("{key} = \"{value}\"\n"));
879        }
880    }
881    let mut rendered = if prefix.is_empty() {
882        cleaned
883    } else {
884        // `strip_codex_proxy_entries` already dropped local keys, so prepend fresh
885        // top-level keys ahead of every existing line.
886        format!("{prefix}{cleaned}")
887    };
888    if let Some(block) = append_block {
889        if !rendered.is_empty() && !rendered.ends_with("\n\n") {
890            rendered.push('\n');
891        }
892        rendered.push_str(block);
893    }
894    rendered
895}
896
897fn render_codex_chatgpt_provider_block(base: &str) -> String {
898    format!(
899        "[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]\n\
900         name = \"OpenAI\"\n\
901         base_url = \"{base}/backend-api/codex\"\n\
902         requires_openai_auth = true\n\
903         supports_websockets = false\n"
904    )
905}
906
907fn strip_top_level_codex_config_key(body: &str, key: &str) -> String {
908    let mut out = Vec::new();
909    let mut in_top_level = true;
910    for line in body.lines() {
911        let t = line.trim_start();
912        if t.starts_with('[') {
913            in_top_level = false;
914        }
915        if in_top_level && toml_assignment_key(t) == Some(key) {
916            continue;
917        }
918        out.push(line);
919    }
920    let s = out.join("\n");
921    if s.is_empty() { s } else { format!("{s}\n") }
922}
923
924/// Remove lean-ctx's own Codex proxy entries from a `config.toml` body: local
925/// top-level proxy URLs, older dead `[env]` URL lines (#554), and the generated
926/// ChatGPT provider block. Custom remote endpoints and profile tables are preserved.
927fn strip_codex_proxy_entries(body: &str) -> String {
928    let lines: Vec<&str> = body.lines().collect();
929    let mut kept: Vec<&str> = Vec::with_capacity(lines.len());
930    let mut current_table: Option<&str> = None;
931    let mut i = 0;
932    while i < lines.len() {
933        let trimmed = lines[i].trim();
934        if is_generated_codex_chatgpt_provider_header(trimmed) {
935            i += 1;
936            while i < lines.len() && !lines[i].trim_start().starts_with('[') {
937                i += 1;
938            }
939            continue;
940        }
941
942        if lines[i].trim_start().starts_with('[') {
943            current_table = Some(trimmed);
944            kept.push(lines[i]);
945            i += 1;
946            continue;
947        }
948
949        if should_strip_codex_proxy_entry(lines[i].trim_start(), current_table) {
950            i += 1;
951            continue;
952        }
953
954        kept.push(lines[i]);
955        i += 1;
956    }
957
958    // Drop an `[env]` header left without any keys after the removal.
959    let mut out: Vec<&str> = Vec::with_capacity(kept.len());
960    let mut i = 0;
961    while i < kept.len() {
962        let trimmed = kept[i].trim();
963        if trimmed == "[env]" {
964            let mut j = i + 1;
965            while j < kept.len() && kept[j].trim().is_empty() {
966                j += 1;
967            }
968            if j >= kept.len() || kept[j].trim_start().starts_with('[') {
969                i = j;
970                continue;
971            }
972        }
973        out.push(kept[i]);
974        i += 1;
975    }
976
977    let mut s = out.join("\n");
978    while s.contains("\n\n\n") {
979        s = s.replace("\n\n\n", "\n\n");
980    }
981    let s = s.trim_end_matches('\n');
982    if s.is_empty() {
983        String::new()
984    } else {
985        format!("{s}\n")
986    }
987}
988
989fn has_top_level_codex_config_key(body: &str, key: &str, predicate: impl Fn(&str) -> bool) -> bool {
990    for line in body.lines() {
991        let t = line.trim_start();
992        if t.starts_with('[') {
993            break;
994        }
995        if toml_assignment_key(t) == Some(key) && predicate(t) {
996            return true;
997        }
998    }
999    false
1000}
1001
1002fn should_strip_codex_proxy_entry(t: &str, current_table: Option<&str>) -> bool {
1003    match current_table {
1004        None => {
1005            is_local_codex_base_url_entry(t, &["openai_base_url", "chatgpt_base_url"])
1006                || is_codex_proxy_model_provider_entry(t)
1007        }
1008        Some("[env]") => is_local_codex_base_url_entry(t, &["OPENAI_BASE_URL", "CHATGPT_BASE_URL"]),
1009        _ => false,
1010    }
1011}
1012
1013fn is_local_codex_base_url_entry(t: &str, keys: &[&str]) -> bool {
1014    toml_assignment_key(t).is_some_and(|key| keys.contains(&key))
1015        && (t.contains("127.0.0.1") || t.contains("localhost"))
1016}
1017
1018fn toml_assignment_key(t: &str) -> Option<&str> {
1019    let key = t.split_once('=')?.0.trim();
1020    if key.is_empty() || key.starts_with('#') {
1021        None
1022    } else {
1023        Some(key)
1024    }
1025}
1026
1027fn is_codex_proxy_model_provider_entry(t: &str) -> bool {
1028    is_toml_string_assignment(t, "model_provider", CODEX_CHATGPT_PROVIDER_ID)
1029        || is_toml_string_assignment(t, "model_provider", "openai")
1030}
1031
1032fn is_toml_string_assignment(t: &str, key: &str, value: &str) -> bool {
1033    let Some((lhs, rhs)) = t.split_once('=') else {
1034        return false;
1035    };
1036    if lhs.trim() != key {
1037        return false;
1038    }
1039    let rhs = rhs.split('#').next().unwrap_or(rhs);
1040    let normalized: String = rhs.chars().filter(|c| !c.is_whitespace()).collect();
1041    normalized == format!("\"{value}\"")
1042}
1043
1044fn is_generated_codex_chatgpt_provider_header(t: &str) -> bool {
1045    t == format!("[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]")
1046}
1047
1048fn codex_config_has_local_proxy_entry(body: &str) -> bool {
1049    let mut current_table: Option<&str> = None;
1050    for line in body.lines() {
1051        let t = line.trim_start();
1052        if is_generated_codex_chatgpt_provider_header(line.trim()) {
1053            return true;
1054        }
1055        if t.starts_with('[') {
1056            current_table = Some(line.trim());
1057            continue;
1058        }
1059        match current_table {
1060            None => {
1061                if is_local_codex_base_url_entry(t, &["openai_base_url", "chatgpt_base_url"])
1062                    || is_toml_string_assignment(t, "model_provider", CODEX_CHATGPT_PROVIDER_ID)
1063                {
1064                    return true;
1065                }
1066            }
1067            Some("[env]")
1068                if is_local_codex_base_url_entry(t, &["OPENAI_BASE_URL", "CHATGPT_BASE_URL"]) =>
1069            {
1070                return true;
1071            }
1072            _ => {}
1073        }
1074    }
1075    false
1076}
1077
1078/// True when Codex will authenticate via a **ChatGPT login** (OAuth) rather than
1079/// an API key. An explicit `OPENAI_API_KEY` in the environment opts into API-key
1080/// mode and overrides the stored login.
1081fn codex_uses_chatgpt_login(home: &Path) -> bool {
1082    if std::env::var("OPENAI_API_KEY").is_ok_and(|v| !v.trim().is_empty()) {
1083        return false;
1084    }
1085    let codex_dir = crate::core::home::resolve_codex_dir().unwrap_or_else(|| home.join(".codex"));
1086    auth_is_chatgpt(&codex_dir)
1087}
1088
1089/// True when `<codex_dir>/auth.json` records a ChatGPT/backend auth mode.
1090/// False when the file is missing, unreadable, or in API-key mode.
1091fn auth_is_chatgpt(codex_dir: &Path) -> bool {
1092    let Ok(content) = std::fs::read_to_string(codex_dir.join("auth.json")) else {
1093        return false;
1094    };
1095    let Ok(doc) = serde_json::from_str::<serde_json::Value>(&content) else {
1096        return false;
1097    };
1098    let Some(mode) = doc.get("auth_mode").and_then(|v| v.as_str()) else {
1099        return false;
1100    };
1101    let normalized = mode
1102        .chars()
1103        .filter(char::is_ascii_alphanumeric)
1104        .collect::<String>()
1105        .to_ascii_lowercase();
1106    matches!(
1107        normalized.as_str(),
1108        "chatgpt" | "chatgptauthtokens" | "personalaccesstoken" | "agentidentity"
1109    )
1110}
1111
1112pub fn default_port() -> u16 {
1113    if let Ok(val) = std::env::var("LEAN_CTX_PROXY_PORT")
1114        && let Ok(port) = val.parse::<u16>()
1115    {
1116        return port;
1117    }
1118    let cfg = crate::core::config::Config::load();
1119    if let Some(port) = cfg.proxy_port {
1120        return port;
1121    }
1122    uid_based_port()
1123}
1124
1125/// Derives a deterministic port from the user's UID to avoid collisions
1126/// on multi-user systems. uid 1000 → 4444, uid 1001 → 4445, etc.
1127/// System accounts (uid < 1000) and root always get the base port 4444.
1128fn uid_based_port() -> u16 {
1129    #[cfg(unix)]
1130    {
1131        // SAFETY: `getuid` takes no arguments, always succeeds, and only reads
1132        // the calling process's real UID — no preconditions, no UB.
1133        let uid = unsafe { libc::getuid() } as u16;
1134        let offset = uid.saturating_sub(1000) % 1000;
1135        DEFAULT_PROXY_PORT + offset
1136    }
1137    #[cfg(not(unix))]
1138    {
1139        DEFAULT_PROXY_PORT
1140    }
1141}
1142
1143#[cfg(test)]
1144mod tests {
1145    use super::*;
1146
1147    #[test]
1148    fn uid_port_first_regular_user() {
1149        // uid 1000 (first regular user on most Linux) → base port
1150        assert_eq!(DEFAULT_PROXY_PORT, 4444);
1151    }
1152
1153    #[test]
1154    fn uid_port_no_overflow() {
1155        // Ensure port stays in valid range even with high UIDs
1156        // uid 2999 → offset (2999-1000) % 1000 = 999 → port 5443
1157        let port = DEFAULT_PROXY_PORT + 999;
1158        assert_eq!(port, 5443);
1159        assert!(port < u16::MAX);
1160    }
1161
1162    #[test]
1163    fn uid_port_system_accounts_get_base() {
1164        // uid < 1000 → saturating_sub gives 0 → base port
1165        let uid: u16 = 500;
1166        let offset = uid.saturating_sub(1000) % 1000;
1167        assert_eq!(DEFAULT_PROXY_PORT + offset, DEFAULT_PROXY_PORT);
1168    }
1169
1170    #[test]
1171    fn proxy_timeout_default_200ms() {
1172        if std::env::var("LEAN_CTX_PROXY_TIMEOUT_MS").is_ok() {
1173            return;
1174        }
1175        assert_eq!(proxy_timeout(), std::time::Duration::from_millis(200));
1176    }
1177
1178    #[test]
1179    fn proxy_timeout_is_non_zero() {
1180        let t = proxy_timeout();
1181        assert!(t.as_millis() > 0);
1182    }
1183
1184    #[test]
1185    fn is_proxy_reachable_returns_false_on_unused_port() {
1186        assert!(!is_proxy_reachable(19999));
1187    }
1188
1189    #[test]
1190    fn posix_block_contains_all_provider_env_vars() {
1191        let base = "http://127.0.0.1:4444";
1192        let block = format!(
1193            r#"{PROXY_ENV_START}
1194export ANTHROPIC_BASE_URL="{base}"
1195export OPENAI_BASE_URL="{base}/v1"
1196export GEMINI_API_BASE_URL="{base}"
1197{PROXY_ENV_END}"#
1198        );
1199        assert!(
1200            block.contains("ANTHROPIC_BASE_URL"),
1201            "shell exports must include ANTHROPIC_BASE_URL"
1202        );
1203        assert!(
1204            block.contains("OPENAI_BASE_URL"),
1205            "shell exports must include OPENAI_BASE_URL"
1206        );
1207        assert!(
1208            block.contains("GEMINI_API_BASE_URL"),
1209            "shell exports must include GEMINI_API_BASE_URL"
1210        );
1211    }
1212
1213    #[test]
1214    fn fish_block_contains_all_provider_env_vars() {
1215        let base = "http://127.0.0.1:4444";
1216        let block = format!(
1217            r#"{PROXY_ENV_START}
1218set -gx ANTHROPIC_BASE_URL "{base}"
1219set -gx OPENAI_BASE_URL "{base}/v1"
1220set -gx GEMINI_API_BASE_URL "{base}"
1221{PROXY_ENV_END}"#
1222        );
1223        assert!(block.contains("ANTHROPIC_BASE_URL"));
1224        assert!(block.contains("OPENAI_BASE_URL"));
1225        assert!(block.contains("GEMINI_API_BASE_URL"));
1226    }
1227
1228    #[test]
1229    fn powershell_block_contains_all_provider_env_vars() {
1230        let base = "http://127.0.0.1:4444";
1231        let block = format!(
1232            r#"{PROXY_ENV_START}
1233$env:ANTHROPIC_BASE_URL = "{base}"
1234$env:OPENAI_BASE_URL = "{base}/v1"
1235$env:GEMINI_API_BASE_URL = "{base}"
1236{PROXY_ENV_END}"#
1237        );
1238        assert!(block.contains("ANTHROPIC_BASE_URL"));
1239        assert!(block.contains("OPENAI_BASE_URL"));
1240        assert!(block.contains("GEMINI_API_BASE_URL"));
1241    }
1242
1243    /// The subscription guard reads the process environment; these tests are only
1244    /// meaningful when the test runner itself does not provide an Anthropic key.
1245    fn env_provides_anthropic_key() -> bool {
1246        std::env::var("ANTHROPIC_API_KEY").is_ok_and(|v| !v.trim().is_empty())
1247            || std::env::var("ANTHROPIC_AUTH_TOKEN").is_ok_and(|v| !v.trim().is_empty())
1248    }
1249
1250    /// `claude_state_dir` honours `CLAUDE_CONFIG_DIR`; when set it would escape the
1251    /// temp HOME and read the real settings file, so skip in that case.
1252    fn claude_dir_overridden() -> bool {
1253        std::env::var("CLAUDE_CONFIG_DIR").is_ok_and(|v| !v.trim().is_empty())
1254    }
1255
1256    fn write_claude_settings(home: &Path, json: &str) -> std::path::PathBuf {
1257        let dir = home.join(".claude");
1258        std::fs::create_dir_all(&dir).unwrap();
1259        let path = dir.join("settings.json");
1260        std::fs::write(&path, json).unwrap();
1261        path
1262    }
1263
1264    #[test]
1265    fn api_key_available_true_with_api_key_helper() {
1266        if claude_dir_overridden() {
1267            return;
1268        }
1269        let home = tempfile::tempdir().unwrap();
1270        write_claude_settings(home.path(), r#"{"apiKeyHelper": "echo sk-test"}"#);
1271        assert!(anthropic_api_key_available(home.path()));
1272    }
1273
1274    #[test]
1275    fn api_key_available_true_with_settings_env_key() {
1276        if claude_dir_overridden() {
1277            return;
1278        }
1279        let home = tempfile::tempdir().unwrap();
1280        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1281        assert!(anthropic_api_key_available(home.path()));
1282    }
1283
1284    #[test]
1285    fn api_key_available_false_without_key() {
1286        if env_provides_anthropic_key() || claude_dir_overridden() {
1287            return;
1288        }
1289        let home = tempfile::tempdir().unwrap();
1290        write_claude_settings(home.path(), r#"{"env": {}}"#);
1291        assert!(!anthropic_api_key_available(home.path()));
1292    }
1293
1294    #[test]
1295    fn api_key_available_false_when_no_settings_file() {
1296        if env_provides_anthropic_key() || claude_dir_overridden() {
1297            return;
1298        }
1299        let home = tempfile::tempdir().unwrap();
1300        assert!(!anthropic_api_key_available(home.path()));
1301    }
1302
1303    #[test]
1304    fn subscription_guard_skips_redirect_without_key() {
1305        if env_provides_anthropic_key() || claude_dir_overridden() {
1306            return;
1307        }
1308        let home = tempfile::tempdir().unwrap();
1309        // No settings file → subscription mode, empty current URL → nothing to repair.
1310        install_claude_env_inner(home.path(), 4444, true, false);
1311        let settings = home.path().join(".claude/settings.json");
1312        assert!(
1313            !settings.exists(),
1314            "subscription mode must not write a proxy redirect"
1315        );
1316    }
1317
1318    #[test]
1319    fn subscription_guard_repairs_stale_local_redirect() {
1320        if env_provides_anthropic_key() || claude_dir_overridden() {
1321            return;
1322        }
1323        let home = tempfile::tempdir().unwrap();
1324        let path = write_claude_settings(
1325            home.path(),
1326            r#"{"env": {"ANTHROPIC_BASE_URL": "http://127.0.0.1:4444"}}"#,
1327        );
1328        install_claude_env_inner(home.path(), 4444, true, false);
1329        let after = std::fs::read_to_string(&path).unwrap();
1330        let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1331        let base = doc
1332            .get("env")
1333            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1334            .and_then(|v| v.as_str())
1335            .unwrap_or("");
1336        assert!(
1337            !is_local_lean_ctx_url(base),
1338            "stale local redirect must be repaired in subscription mode, got {base:?}"
1339        );
1340    }
1341
1342    /// API-key mode must STILL route Claude through the proxy (we only protect
1343    /// subscriptions; pay-as-you-go users keep their compression). Uses a real bound
1344    /// port so `is_proxy_reachable` passes, exercising the full production path.
1345    #[test]
1346    fn install_redirects_claude_when_api_key_present() {
1347        if claude_dir_overridden() {
1348            return;
1349        }
1350        let home = tempfile::tempdir().unwrap();
1351        // API-key mode declared in settings.json → deterministic regardless of env.
1352        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1353        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1354        let port = listener.local_addr().unwrap().port();
1355
1356        install_claude_env_inner(home.path(), port, true, false);
1357
1358        let after = std::fs::read_to_string(home.path().join(".claude/settings.json")).unwrap();
1359        let doc: serde_json::Value = crate::core::jsonc::parse_jsonc(&after).unwrap();
1360        let base = doc
1361            .get("env")
1362            .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
1363            .and_then(|v| v.as_str())
1364            .unwrap_or("");
1365        assert_eq!(
1366            base,
1367            format!("http://127.0.0.1:{port}"),
1368            "API-key mode must route Claude through the proxy"
1369        );
1370    }
1371
1372    /// Shell export: subscription mode keeps OpenAI/Gemini but omits the ANTHROPIC line
1373    /// (replaced by an explanatory comment), so a shell-launched Claude stays on
1374    /// api.anthropic.com.
1375    #[test]
1376    fn shell_export_omits_anthropic_without_key() {
1377        if env_provides_anthropic_key() || claude_dir_overridden() {
1378            return;
1379        }
1380        let home = tempfile::tempdir().unwrap();
1381        std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1382        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1383        let port = listener.local_addr().unwrap().port();
1384
1385        install_shell_exports(home.path(), port, true);
1386
1387        let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1388        assert!(
1389            rc.contains(&format!(
1390                "export OPENAI_BASE_URL=\"http://127.0.0.1:{port}/v1\""
1391            )),
1392            "OpenAI export must remain and carry the /v1 suffix (#366)"
1393        );
1394        assert!(
1395            rc.contains(&format!(
1396                "export GEMINI_API_BASE_URL=\"http://127.0.0.1:{port}\""
1397            )),
1398            "Gemini export must remain WITHOUT /v1 (SDK appends /v1beta itself)"
1399        );
1400        assert!(
1401            !rc.contains("export ANTHROPIC_BASE_URL="),
1402            "ANTHROPIC export must be omitted in subscription mode"
1403        );
1404        assert!(
1405            rc.contains(ANTHROPIC_OMITTED_NOTE),
1406            "omission must be explained in the RC block"
1407        );
1408    }
1409
1410    /// Codex CLI config: a fresh install writes the `/v1`-suffixed proxy URL (#366).
1411    #[test]
1412    fn codex_env_writes_v1_suffixed_url() {
1413        let dir = tempfile::tempdir().unwrap();
1414        let codex_dir = dir.path().join(".codex");
1415        std::fs::create_dir_all(&codex_dir).unwrap();
1416        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1417        let port = listener.local_addr().unwrap().port();
1418
1419        install_codex_env_at(&codex_dir, port, true);
1420
1421        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1422        assert!(
1423            cfg.contains(&format!("openai_base_url = \"http://127.0.0.1:{port}/v1\"")),
1424            "Codex config must set top-level openai_base_url with the /v1 suffix, got:\n{cfg}"
1425        );
1426        assert!(
1427            !cfg.contains("[env]") && !cfg.contains("OPENAI_BASE_URL"),
1428            "must not write the dead [env] OPENAI_BASE_URL form (#554), got:\n{cfg}"
1429        );
1430        assert!(
1431            !cfg.contains(CODEX_CHATGPT_PROVIDER_ID),
1432            "API-key mode must not install the ChatGPT-only provider, got:\n{cfg}"
1433        );
1434        assert!(
1435            !cfg.contains("chatgpt_base_url"),
1436            "API-key mode must not install the ChatGPT backend rail, got:\n{cfg}"
1437        );
1438    }
1439
1440    /// Codex CLI config: a legacy `[env] OPENAI_BASE_URL` line (which Codex never
1441    /// read, #554) is removed and replaced by a top-level `openai_base_url`, even
1442    /// when stale (missing `/v1`). The dead `[env]` table is collapsed.
1443    #[test]
1444    fn codex_env_migrates_legacy_env_entry() {
1445        let dir = tempfile::tempdir().unwrap();
1446        let codex_dir = dir.path().join(".codex");
1447        std::fs::create_dir_all(&codex_dir).unwrap();
1448        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1449        let port = listener.local_addr().unwrap().port();
1450        std::fs::write(
1451            codex_dir.join("config.toml"),
1452            format!(
1453                "model = \"gpt-5.2\"\n\n[env]\nOPENAI_BASE_URL = \"http://127.0.0.1:{port}\"\n"
1454            ),
1455        )
1456        .unwrap();
1457
1458        install_codex_env_at(&codex_dir, port, true);
1459
1460        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1461        assert!(
1462            cfg.contains(&format!("openai_base_url = \"http://127.0.0.1:{port}/v1\"")),
1463            "legacy entry must become a top-level openai_base_url (/v1), got:\n{cfg}"
1464        );
1465        assert!(
1466            cfg.contains("model = \"gpt-5.2\""),
1467            "unrelated config must be preserved"
1468        );
1469        assert!(
1470            !cfg.contains("OPENAI_BASE_URL"),
1471            "dead legacy [env] OPENAI_BASE_URL must be removed, got:\n{cfg}"
1472        );
1473        assert!(
1474            !cfg.contains("[env]"),
1475            "empty [env] table must be collapsed, got:\n{cfg}"
1476        );
1477    }
1478
1479    /// Codex CLI config: a custom non-local `openai_base_url` is never rewritten.
1480    #[test]
1481    fn codex_env_preserves_custom_remote_endpoint() {
1482        let dir = tempfile::tempdir().unwrap();
1483        let codex_dir = dir.path().join(".codex");
1484        std::fs::create_dir_all(&codex_dir).unwrap();
1485        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1486        let port = listener.local_addr().unwrap().port();
1487        let original = "openai_base_url = \"https://my-gateway.example.com/v1\"\n";
1488        std::fs::write(codex_dir.join("config.toml"), original).unwrap();
1489
1490        install_codex_env_at(&codex_dir, port, true);
1491
1492        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1493        assert!(
1494            cfg.contains("https://my-gateway.example.com/v1"),
1495            "custom remote endpoint must be preserved, got:\n{cfg}"
1496        );
1497        assert!(
1498            !cfg.contains("127.0.0.1"),
1499            "proxy URL must not be injected over a custom endpoint"
1500        );
1501    }
1502
1503    #[test]
1504    fn codex_env_chatgpt_mode_writes_subscription_provider() {
1505        let dir = tempfile::tempdir().unwrap();
1506        let codex_dir = dir.path().join(".codex");
1507        std::fs::create_dir_all(&codex_dir).unwrap();
1508        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1509        let port = listener.local_addr().unwrap().port();
1510        std::fs::write(
1511            codex_dir.join("config.toml"),
1512            "model_provider = \"custom\"\nchatgpt_base_url = \"https://chatgpt.example.com/backend-api/\"\nmodel = \"gpt-5.5\"\n",
1513        )
1514        .unwrap();
1515
1516        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1517
1518        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1519        assert!(
1520            !cfg.contains("openai_base_url"),
1521            "ChatGPT mode must not write a proxy openai_base_url, got:\n{cfg}"
1522        );
1523        assert!(
1524            cfg.contains(&format!("model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"")),
1525            "ChatGPT mode must select the lean-ctx ChatGPT provider, got:\n{cfg}"
1526        );
1527        assert!(
1528            !cfg.contains("model_provider = \"custom\""),
1529            "ChatGPT mode must replace stale top-level model_provider, got:\n{cfg}"
1530        );
1531        assert!(
1532            !cfg.contains("chatgpt_base_url"),
1533            "ChatGPT mode must leave aux/apps rail native, got:\n{cfg}"
1534        );
1535        assert!(
1536            cfg.contains(&format!("[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]")),
1537            "ChatGPT mode must install the generated provider block, got:\n{cfg}"
1538        );
1539        assert!(
1540            cfg.contains(&format!(
1541                "base_url = \"http://127.0.0.1:{port}/backend-api/codex\""
1542            )),
1543            "ChatGPT provider must target the Codex backend rail, got:\n{cfg}"
1544        );
1545        assert!(
1546            cfg.contains("model = \"gpt-5.5\""),
1547            "user keys are preserved, got:\n{cfg}"
1548        );
1549    }
1550
1551    /// #597-safe default: a ChatGPT login with the opt-in OFF must leave Codex
1552    /// native — no `model_provider` pin (which would scope/hide history), no
1553    /// `chatgpt_base_url`, no provider block, no proxy URL at all.
1554    #[test]
1555    fn codex_env_chatgpt_mode_optout_writes_nothing() {
1556        let dir = tempfile::tempdir().unwrap();
1557        let codex_dir = dir.path().join(".codex");
1558        std::fs::create_dir_all(&codex_dir).unwrap();
1559        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1560        let port = listener.local_addr().unwrap().port();
1561        std::fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap();
1562
1563        // Opt-in OFF (chatgpt_proxy = false).
1564        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, false);
1565
1566        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1567        assert!(
1568            !cfg.contains("model_provider"),
1569            "opt-out must not pin a model_provider (#597), got:\n{cfg}"
1570        );
1571        assert!(
1572            !cfg.contains("chatgpt_base_url") && !cfg.contains("openai_base_url"),
1573            "opt-out must not write any proxy base URL, got:\n{cfg}"
1574        );
1575        assert!(
1576            !cfg.contains(CODEX_CHATGPT_PROVIDER_ID) && !cfg.contains("127.0.0.1"),
1577            "opt-out must not install the provider block or any proxy URL, got:\n{cfg}"
1578        );
1579        assert!(cfg.contains("model = \"gpt-5.5\""), "user keys preserved");
1580    }
1581
1582    /// Flipping the opt-in OFF after it was ON strips the provider config back to
1583    /// native, so Codex history + cloud/remote return (#597).
1584    #[test]
1585    fn codex_env_chatgpt_optin_toggle_off_restores_native() {
1586        let dir = tempfile::tempdir().unwrap();
1587        let codex_dir = dir.path().join(".codex");
1588        std::fs::create_dir_all(&codex_dir).unwrap();
1589        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1590        let port = listener.local_addr().unwrap().port();
1591        std::fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap();
1592
1593        // ON → provider config present.
1594        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1595        let on = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1596        assert!(
1597            on.contains(CODEX_CHATGPT_PROVIDER_ID),
1598            "opt-in writes provider"
1599        );
1600
1601        // OFF → stripped back to native.
1602        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, false);
1603        let off = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1604        assert!(
1605            !off.contains("model_provider")
1606                && !off.contains("chatgpt_base_url")
1607                && !off.contains(CODEX_CHATGPT_PROVIDER_ID)
1608                && !off.contains("127.0.0.1"),
1609            "toggling opt-in off restores native config, got:\n{off}"
1610        );
1611        assert!(off.contains("model = \"gpt-5.5\""), "user keys preserved");
1612    }
1613
1614    /// With the opt-in enabled, ChatGPT subscription mode writes only the model
1615    /// provider. It must leave `chatgpt_base_url` native so Codex Apps MCP keeps
1616    /// first-party ChatGPT auth cookies/headers.
1617    #[test]
1618    fn codex_env_chatgpt_mode_writes_backend_url_idempotently() {
1619        let dir = tempfile::tempdir().unwrap();
1620        let codex_dir = dir.path().join(".codex");
1621        std::fs::create_dir_all(&codex_dir).unwrap();
1622        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1623        let port = listener.local_addr().unwrap().port();
1624        std::fs::write(codex_dir.join("config.toml"), "model = \"gpt-5.5\"\n").unwrap();
1625
1626        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1627
1628        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1629        assert!(
1630            cfg.contains(&format!("model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"")),
1631            "ChatGPT mode must pin the lean-ctx provider, got:\n{cfg}"
1632        );
1633        assert!(
1634            !cfg.contains("chatgpt_base_url"),
1635            "ChatGPT mode must not proxy aux/apps via chatgpt_base_url, got:\n{cfg}"
1636        );
1637        assert!(
1638            !cfg.contains("openai_base_url"),
1639            "ChatGPT mode routes via the generated provider, not the /v1 openai_base_url, got:\n{cfg}"
1640        );
1641        assert!(
1642            cfg.contains("model = \"gpt-5.5\""),
1643            "user keys are preserved, got:\n{cfg}"
1644        );
1645
1646        // Idempotent: a second run yields the identical body ("already configured").
1647        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1648        let again = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1649        assert_eq!(cfg, again, "opt-in render must be idempotent");
1650
1651        // Switching to API-key mode strips the ChatGPT-only rail.
1652        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ApiKey, false);
1653        let off = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1654        assert!(
1655            !off.contains("chatgpt_base_url") && !off.contains(CODEX_CHATGPT_PROVIDER_ID),
1656            "API-key mode must remove ChatGPT-only config, got:\n{off}"
1657        );
1658        assert!(off.contains(&format!("openai_base_url = \"http://127.0.0.1:{port}/v1\"")));
1659        assert!(off.contains("model = \"gpt-5.5\""));
1660    }
1661
1662    /// Upgrade over old ChatGPT-proxy entries strips stale aux/app routing first,
1663    /// then writes the current ChatGPT subscription model provider config.
1664    #[test]
1665    fn codex_chatgpt_upgrade_strips_legacy_leanctx_provider() {
1666        let dir = tempfile::tempdir().unwrap();
1667        let codex_dir = dir.path().join(".codex");
1668        std::fs::create_dir_all(&codex_dir).unwrap();
1669        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1670        let port = listener.local_addr().unwrap().port();
1671        // Realistic legacy layout: lean-ctx prepended its keys at the top and
1672        // appended the provider block last, so user content sat in between.
1673        let legacy = format!(
1674            "model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"\n\
1675             openai_base_url = \"http://127.0.0.1:{port}/backend-api/codex\"\n\
1676             chatgpt_base_url = \"http://127.0.0.1:{port}/backend-api\"\n\
1677             model = \"gpt-5.5\"\n\n\
1678             {LEGACY_CHATGPT_PROVIDER_BLOCK}"
1679        );
1680        std::fs::write(codex_dir.join("config.toml"), legacy).unwrap();
1681
1682        install_codex_env_at_mode(&codex_dir, port, true, CodexProxyMode::ChatGpt, true);
1683
1684        let cfg = std::fs::read_to_string(codex_dir.join("config.toml")).unwrap();
1685        assert!(
1686            !cfg.contains("openai_base_url"),
1687            "backend-api openai_base_url override must be removed (breaks remote), got:\n{cfg}"
1688        );
1689        assert!(
1690            cfg.contains(&format!("model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"")),
1691            "current ChatGPT provider must be written, got:\n{cfg}"
1692        );
1693        assert!(
1694            !cfg.contains("chatgpt_base_url"),
1695            "stale ChatGPT aux/app routing must be removed, got:\n{cfg}"
1696        );
1697        assert!(cfg.contains("model = \"gpt-5.5\""));
1698    }
1699
1700    /// `render_codex_config` is idempotent: applying it to an already-configured
1701    /// body yields the identical body (so `install` reports "already configured").
1702    #[test]
1703    fn render_codex_config_is_idempotent() {
1704        let entries = vec![("openai_base_url", "http://127.0.0.1:4444/v1".to_string())];
1705        let once = render_codex_config("model = \"gpt-5.5\"\n", &entries, None);
1706        let twice = render_codex_config(&once, &entries, None);
1707        assert_eq!(once, twice, "render must be idempotent");
1708        assert!(once.starts_with("openai_base_url = \"http://127.0.0.1:4444/v1\"\n"));
1709        assert!(once.contains("model = \"gpt-5.5\""));
1710    }
1711
1712    /// The `[model_providers.leanctx-chatgpt]` block lean-ctx wrote before #597.
1713    /// Kept verbatim here so the strip/auto-heal tests exercise a real legacy body
1714    /// even though the renderer no longer produces it.
1715    const LEGACY_CHATGPT_PROVIDER_BLOCK: &str = "[model_providers.leanctx-chatgpt]\n\
1716         name = \"OpenAI\"\n\
1717         base_url = \"http://127.0.0.1:4444/backend-api/codex\"\n\
1718         requires_openai_auth = true\n\
1719         supports_websockets = false\n";
1720
1721    #[test]
1722    fn strip_codex_proxy_entries_preserves_nested_model_provider() {
1723        let body = format!(
1724            "model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"\n\
1725             openai_base_url = \"http://127.0.0.1:4444/backend-api/codex\"\n\
1726             chatgpt_base_url = \"http://127.0.0.1:4444/backend-api\"\n\n\
1727             {LEGACY_CHATGPT_PROVIDER_BLOCK}\n\
1728             [profiles.work]\n\
1729             model_provider = \"openai\"\n\
1730             openai_base_url = \"http://127.0.0.1:9999/v1\"\n"
1731        );
1732
1733        let out = strip_codex_proxy_entries(&body);
1734
1735        assert!(
1736            !out.contains(&format!("[model_providers.{CODEX_CHATGPT_PROVIDER_ID}]")),
1737            "generated provider block must be removed, got:\n{out}"
1738        );
1739        assert!(
1740            out.contains(
1741                "[profiles.work]\nmodel_provider = \"openai\"\nopenai_base_url = \"http://127.0.0.1:9999/v1\""
1742            ),
1743            "profile provider config must be preserved, got:\n{out}"
1744        );
1745    }
1746
1747    #[test]
1748    fn codex_proxy_cleanup_detection_ignores_plain_openai_provider() {
1749        assert!(!codex_config_has_local_proxy_entry(
1750            "model_provider = \"openai\"\n"
1751        ));
1752        assert!(codex_config_has_local_proxy_entry(&format!(
1753            "model_provider = \"{CODEX_CHATGPT_PROVIDER_ID}\"\n"
1754        )));
1755    }
1756
1757    /// `render_codex_config` inserts the key as a *top-level* key (before the first
1758    /// `[table]`), otherwise Codex would read it as a sub-key and ignore it.
1759    #[test]
1760    fn render_codex_config_inserts_before_first_table() {
1761        let body = "model = \"gpt-5.5\"\n\n[features]\nhooks = true\n";
1762        let entries = vec![("openai_base_url", "http://127.0.0.1:4444/v1".to_string())];
1763        let out = render_codex_config(body, &entries, None);
1764        let key_idx = out.find("openai_base_url").expect("key present");
1765        let table_idx = out.find("[features]").expect("table present");
1766        assert!(
1767            key_idx < table_idx,
1768            "openai_base_url must precede the first table, got:\n{out}"
1769        );
1770    }
1771
1772    /// `auth_is_chatgpt` reflects Codex's `auth.json` auth mode.
1773    #[test]
1774    fn auth_is_chatgpt_detects_login_mode() {
1775        let dir = tempfile::tempdir().unwrap();
1776        let codex_dir = dir.path().join(".codex");
1777        std::fs::create_dir_all(&codex_dir).unwrap();
1778
1779        assert!(!auth_is_chatgpt(&codex_dir), "no auth.json => not chatgpt");
1780
1781        std::fs::write(
1782            codex_dir.join("auth.json"),
1783            r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-test"}"#,
1784        )
1785        .unwrap();
1786        assert!(!auth_is_chatgpt(&codex_dir), "apikey mode => not chatgpt");
1787
1788        std::fs::write(
1789            codex_dir.join("auth.json"),
1790            r#"{"auth_mode":"chatgpt","tokens":{"access_token":"x"}}"#,
1791        )
1792        .unwrap();
1793        assert!(auth_is_chatgpt(&codex_dir), "chatgpt mode => true");
1794
1795        for mode in ["chatgptAuthTokens", "personalAccessToken", "agentIdentity"] {
1796            std::fs::write(
1797                codex_dir.join("auth.json"),
1798                format!(r#"{{"auth_mode":"{mode}","tokens":{{"access_token":"x"}}}}"#),
1799            )
1800            .unwrap();
1801            assert!(auth_is_chatgpt(&codex_dir), "{mode} => true");
1802        }
1803    }
1804
1805    /// Shell export: API-key mode includes the ANTHROPIC export (symmetry check).
1806    #[test]
1807    fn shell_export_includes_anthropic_with_key() {
1808        if claude_dir_overridden() {
1809            return;
1810        }
1811        let home = tempfile::tempdir().unwrap();
1812        std::fs::write(home.path().join(".zshrc"), "# user rc\n").unwrap();
1813        write_claude_settings(home.path(), r#"{"env": {"ANTHROPIC_API_KEY": "sk-test"}}"#);
1814        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1815        let port = listener.local_addr().unwrap().port();
1816
1817        install_shell_exports(home.path(), port, true);
1818
1819        let rc = std::fs::read_to_string(home.path().join(".zshrc")).unwrap();
1820        assert!(
1821            rc.contains(&format!(
1822                "export ANTHROPIC_BASE_URL=\"http://127.0.0.1:{port}\""
1823            )),
1824            "API-key mode must export ANTHROPIC_BASE_URL"
1825        );
1826    }
1827
1828    fn read_pi_models(agent_dir: &Path) -> serde_json::Value {
1829        let raw = std::fs::read_to_string(agent_dir.join("models.json")).unwrap();
1830        crate::core::jsonc::parse_jsonc(&raw).unwrap()
1831    }
1832
1833    /// #361: `proxy enable` must reach Pi/forge, which read `providers.*.baseUrl`
1834    /// from models.json (not ANTHROPIC_BASE_URL). Fresh install wires both
1835    /// providers with the per-SDK URL convention (anthropic bare, openai `/v1`).
1836    #[test]
1837    fn pi_env_fresh_install_writes_both_providers() {
1838        let dir = tempfile::tempdir().unwrap();
1839        let agent_dir = dir.path().join(".pi/agent");
1840        std::fs::create_dir_all(&agent_dir).unwrap();
1841        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1842        let port = listener.local_addr().unwrap().port();
1843
1844        install_pi_env_at(&agent_dir, port, true, false);
1845
1846        let doc = read_pi_models(&agent_dir);
1847        assert_eq!(
1848            pi_provider_base_url(&doc, "anthropic"),
1849            format!("http://127.0.0.1:{port}"),
1850            "Anthropic gets the bare origin (SDK appends /v1 itself)"
1851        );
1852        assert_eq!(
1853            pi_provider_base_url(&doc, "openai"),
1854            format!("http://127.0.0.1:{port}/v1"),
1855            "OpenAI gets the /v1-suffixed URL (#366)"
1856        );
1857    }
1858
1859    /// A user's custom remote gateway must survive `proxy enable` (no --force):
1860    /// only the untouched provider is pointed at the proxy.
1861    #[test]
1862    fn pi_env_preserves_custom_remote_endpoint_without_force() {
1863        let dir = tempfile::tempdir().unwrap();
1864        let agent_dir = dir.path().join(".pi/agent");
1865        std::fs::create_dir_all(&agent_dir).unwrap();
1866        std::fs::write(
1867            agent_dir.join("models.json"),
1868            r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1869        )
1870        .unwrap();
1871        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1872        let port = listener.local_addr().unwrap().port();
1873
1874        install_pi_env_at(&agent_dir, port, true, false);
1875
1876        let doc = read_pi_models(&agent_dir);
1877        assert_eq!(
1878            pi_provider_base_url(&doc, "anthropic"),
1879            "https://gw.example.com",
1880            "custom remote endpoint must be preserved without --force"
1881        );
1882        assert_eq!(
1883            pi_provider_base_url(&doc, "openai"),
1884            format!("http://127.0.0.1:{port}/v1"),
1885            "the untouched provider still gets the proxy"
1886        );
1887    }
1888
1889    /// `--force` (the `proxy enable --force` path) overrides a custom endpoint.
1890    #[test]
1891    fn pi_env_force_overrides_custom_endpoint() {
1892        let dir = tempfile::tempdir().unwrap();
1893        let agent_dir = dir.path().join(".pi/agent");
1894        std::fs::create_dir_all(&agent_dir).unwrap();
1895        std::fs::write(
1896            agent_dir.join("models.json"),
1897            r#"{"providers":{"anthropic":{"baseUrl":"https://gw.example.com"}}}"#,
1898        )
1899        .unwrap();
1900        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1901        let port = listener.local_addr().unwrap().port();
1902
1903        install_pi_env_at(&agent_dir, port, true, true);
1904
1905        let doc = read_pi_models(&agent_dir);
1906        assert_eq!(
1907            pi_provider_base_url(&doc, "anthropic"),
1908            format!("http://127.0.0.1:{port}"),
1909            "--force must override the custom endpoint"
1910        );
1911    }
1912
1913    /// A user without Pi installed must not get a Pi config materialized.
1914    #[test]
1915    fn pi_env_skips_when_agent_dir_absent() {
1916        let dir = tempfile::tempdir().unwrap();
1917        let agent_dir = dir.path().join(".pi/agent");
1918
1919        install_pi_env_at(&agent_dir, 19999, true, false);
1920
1921        assert!(
1922            !agent_dir.join("models.json").exists(),
1923            "no Pi config must be created when Pi is not configured"
1924        );
1925    }
1926
1927    /// `disable` reverts only the providers pointing at the local proxy; a
1928    /// user-owned custom endpoint is left untouched.
1929    #[test]
1930    fn pi_uninstall_removes_only_local_endpoints() {
1931        let dir = tempfile::tempdir().unwrap();
1932        let agent_dir = dir.path().join(".pi/agent");
1933        std::fs::create_dir_all(&agent_dir).unwrap();
1934        std::fs::write(
1935            agent_dir.join("models.json"),
1936            r#"{"providers":{"anthropic":{"baseUrl":"http://127.0.0.1:4444"},"openai":{"baseUrl":"https://api.openai.com/v1"}}}"#,
1937        )
1938        .unwrap();
1939
1940        uninstall_pi_env_at(&agent_dir, true);
1941
1942        let doc = read_pi_models(&agent_dir);
1943        assert_eq!(
1944            pi_provider_base_url(&doc, "anthropic"),
1945            "",
1946            "the local proxy endpoint we set must be removed"
1947        );
1948        assert_eq!(
1949            pi_provider_base_url(&doc, "openai"),
1950            "https://api.openai.com/v1",
1951            "a custom endpoint must be preserved on disable"
1952        );
1953    }
1954}