Skip to main content

codex_helper_core/
codex_integration.rs

1use std::collections::BTreeSet;
2use std::fs;
3use std::io::{Read, Seek, SeekFrom};
4use std::path::{Path, PathBuf};
5
6use crate::client_config::{
7    CLAUDE_ABSENT_BACKUP_SENTINEL, claude_settings_backup_path_for as claude_settings_backup_path,
8    claude_settings_path, codex_app_db_path, codex_auth_path, codex_config_path,
9    codex_switch_state_path,
10};
11use crate::codex_patch_plan::{
12    CodexAuthPatchPlan, CodexFeatureBoolPatch, CodexPatchPlan, CodexSwitchOnEffectOrder,
13    CodexTomlBoolPatch,
14};
15pub use crate::codex_patch_plan::{
16    CodexCompactionStrategy, CodexHostedImageGenerationMode, CodexPatchMode, CodexSwitchOptions,
17};
18use crate::config::{ProxyConfig, ProxyConfigV2, ProxyConfigV4, RoutingAffinityPolicyV5};
19use crate::file_replace::write_text_file;
20use anyhow::{Context, Result, anyhow};
21use toml::Value;
22use toml_edit::{
23    Document as EditableTomlDocument, Item as EditableTomlItem, Table as EditableTomlTable,
24    Value as EditableTomlValue, value as editable_toml_value,
25};
26
27fn read_config_text(path: &Path) -> Result<String> {
28    if !path.exists() {
29        return Ok(String::new());
30    }
31    let mut file = fs::File::open(path).with_context(|| format!("open {:?}", path))?;
32    let mut buf = String::new();
33    file.read_to_string(&mut buf)
34        .with_context(|| format!("read {:?}", path))?;
35    Ok(buf)
36}
37
38fn atomic_write(path: &Path, data: &str) -> Result<()> {
39    write_text_file(path, data)
40}
41
42fn set_toml_value_preserving_decor(item: &mut EditableTomlItem, mut value: EditableTomlValue) {
43    if let Some(current) = item.as_value_mut() {
44        let decor = current.decor().clone();
45        *value.decor_mut() = decor;
46        *current = value;
47    } else {
48        *item = EditableTomlItem::Value(value);
49    }
50}
51
52fn set_toml_string(table: &mut EditableTomlTable, key: &str, value: impl Into<String>) {
53    let item = table.entry(key).or_insert(EditableTomlItem::None);
54    set_toml_value_preserving_decor(item, EditableTomlValue::from(value.into()));
55}
56
57fn toml_string(table: &EditableTomlTable, key: &str) -> Option<String> {
58    table
59        .get(key)
60        .and_then(EditableTomlItem::as_value)
61        .and_then(EditableTomlValue::as_str)
62        .map(ToOwned::to_owned)
63}
64
65fn local_helper_proxy_item(item: Option<&EditableTomlItem>) -> bool {
66    let Some(table) = item.and_then(EditableTomlItem::as_table) else {
67        return false;
68    };
69    let name_is_helper = toml_string(table, "name").as_deref() == Some("codex-helper");
70    let base_url_is_local = toml_string(table, "base_url")
71        .as_deref()
72        .is_some_and(|url| url.contains("127.0.0.1") || url.contains("localhost"));
73    name_is_helper || base_url_is_local
74}
75
76fn codex_text_points_to_local_helper(text: &str) -> Result<bool> {
77    if text.trim().is_empty() {
78        return Ok(false);
79    }
80    let doc = text.parse::<EditableTomlDocument>()?;
81    let root = doc.as_table();
82    if toml_string(root, "model_provider").as_deref() != Some("codex_proxy") {
83        return Ok(false);
84    }
85    Ok(root
86        .get("model_providers")
87        .and_then(EditableTomlItem::as_table)
88        .and_then(|table| table.get("codex_proxy"))
89        .is_some_and(|item| local_helper_proxy_item(Some(item))))
90}
91
92#[derive(Debug, Clone, PartialEq, serde::Deserialize, serde::Serialize)]
93struct CodexSwitchState {
94    version: u32,
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    patch_mode: Option<CodexPatchMode>,
97    #[serde(default, skip_serializing_if = "bool_is_false")]
98    responses_websocket: bool,
99    #[serde(default, skip_serializing_if = "CodexCompactionStrategy::is_auto")]
100    compaction: CodexCompactionStrategy,
101    original_config_absent: bool,
102    original_model_provider: Option<String>,
103    original_codex_proxy: Option<Value>,
104    had_model_providers: bool,
105    #[serde(default)]
106    original_auth_json_absent: bool,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    original_auth_json: Option<String>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    patched_auth_json: Option<String>,
111}
112
113impl CodexSwitchState {
114    fn from_codex_config_text(text: &str, original_config_absent: bool) -> Result<Self> {
115        let doc = if text.trim().is_empty() {
116            EditableTomlDocument::new()
117        } else {
118            text.parse::<EditableTomlDocument>()?
119        };
120        let root = doc.as_table();
121        let providers_table = root
122            .get("model_providers")
123            .and_then(EditableTomlItem::as_table);
124
125        Ok(Self {
126            version: 2,
127            patch_mode: None,
128            responses_websocket: false,
129            compaction: CodexCompactionStrategy::Auto,
130            original_config_absent,
131            original_model_provider: toml_string(root, "model_provider"),
132            original_codex_proxy: original_codex_proxy_value(text)?,
133            had_model_providers: providers_table.is_some(),
134            original_auth_json_absent: false,
135            original_auth_json: None,
136            patched_auth_json: None,
137        })
138    }
139
140    fn set_auth_patch(&mut self, patch: &CodexAuthPatch) {
141        self.original_auth_json_absent = patch.original_absent;
142        self.original_auth_json = patch.original_text.clone();
143        self.patched_auth_json = Some(patch.patched_text.clone());
144    }
145
146    fn clear_auth_patch(&mut self) {
147        self.original_auth_json_absent = false;
148        self.original_auth_json = None;
149        self.patched_auth_json = None;
150    }
151}
152
153fn original_codex_proxy_value(text: &str) -> Result<Option<Value>> {
154    if text.trim().is_empty() {
155        return Ok(None);
156    }
157    let value = text.parse::<Value>()?;
158    Ok(value
159        .as_table()
160        .and_then(|root| root.get("model_providers"))
161        .and_then(Value::as_table)
162        .and_then(|providers| providers.get("codex_proxy"))
163        .cloned())
164}
165
166fn editable_item_from_toml_value(value: &Value) -> Result<EditableTomlItem> {
167    match value {
168        Value::Table(table) => {
169            let body = toml::to_string(table)?;
170            let doc = format!("[codex_proxy]\n{body}").parse::<EditableTomlDocument>()?;
171            doc.as_table()
172                .get("codex_proxy")
173                .cloned()
174                .ok_or_else(|| anyhow!("failed to parse stored codex_proxy state"))
175        }
176        _ => Err(anyhow!("stored codex_proxy state must be a TOML table")),
177    }
178}
179
180fn read_codex_switch_state() -> Result<Option<CodexSwitchState>> {
181    let path = codex_switch_state_path();
182    if !path.exists() {
183        return Ok(None);
184    }
185    let text = read_config_text(&path)?;
186    let state = serde_json::from_str::<CodexSwitchState>(&text)
187        .with_context(|| format!("parse {:?}", path))?;
188    Ok(Some(state))
189}
190
191fn write_codex_switch_state(state: &CodexSwitchState) -> Result<()> {
192    let path = codex_switch_state_path();
193    let text = serde_json::to_string_pretty(state)?;
194    atomic_write(&path, &text)
195}
196
197pub fn codex_switch_state_exists() -> bool {
198    codex_switch_state_path().exists()
199}
200
201fn bool_is_false(value: &bool) -> bool {
202    !*value
203}
204
205enum CodexSwitchOffEdit {
206    Write(String),
207    RemoveFile,
208}
209
210struct CodexAuthPatch {
211    original_absent: bool,
212    original_text: Option<String>,
213    patched_text: String,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217enum CodexAuthEdit {
218    None,
219    Write(String),
220    RemoveFile,
221}
222
223fn auth_json_matches_helper_patch(current_text: Option<&str>, patched_auth_json: &str) -> bool {
224    let Some(current_text) = current_text else {
225        return false;
226    };
227    if current_text == patched_auth_json {
228        return true;
229    }
230
231    let Ok(current_value) = serde_json::from_str::<serde_json::Value>(current_text) else {
232        return false;
233    };
234    let Ok(patched_value) = serde_json::from_str::<serde_json::Value>(patched_auth_json) else {
235        return false;
236    };
237    current_value == patched_value
238}
239
240fn apply_codex_auth_edit(edit: CodexAuthEdit) -> Result<()> {
241    match edit {
242        CodexAuthEdit::None => Ok(()),
243        CodexAuthEdit::Write(text) => atomic_write(&codex_auth_path(), &text)
244            .with_context(|| format!("patch {:?}", codex_auth_path())),
245        CodexAuthEdit::RemoveFile => {
246            let path = codex_auth_path();
247            if path.exists() {
248                fs::remove_file(&path).with_context(|| format!("remove {:?}", path))?;
249            }
250            Ok(())
251        }
252    }
253}
254
255fn read_current_codex_auth_text() -> Result<Option<String>> {
256    let path = codex_auth_path();
257    if path.exists() {
258        read_config_text(&path).map(Some)
259    } else {
260        Ok(None)
261    }
262}
263
264fn auth_restore_edit_from_state(state: &mut CodexSwitchState) -> Result<CodexAuthEdit> {
265    let current_text = read_current_codex_auth_text()?;
266    Ok(auth_restore_edit_from_state_and_current(
267        state,
268        current_text.as_deref(),
269    ))
270}
271
272fn auth_restore_edit_from_state_and_current(
273    state: &mut CodexSwitchState,
274    current_text: Option<&str>,
275) -> CodexAuthEdit {
276    let Some(patched_auth_json) = state.patched_auth_json.as_deref() else {
277        return CodexAuthEdit::None;
278    };
279
280    let edit = if auth_json_matches_helper_patch(current_text, patched_auth_json) {
281        if state.original_auth_json_absent {
282            CodexAuthEdit::RemoveFile
283        } else if let Some(original) = state.original_auth_json.clone() {
284            CodexAuthEdit::Write(original)
285        } else {
286            CodexAuthEdit::None
287        }
288    } else {
289        CodexAuthEdit::None
290    };
291
292    state.clear_auth_patch();
293    edit
294}
295
296fn auth_baseline_for_patch_from_current(
297    state: &CodexSwitchState,
298    current_text: Option<String>,
299) -> (bool, Option<String>) {
300    if let Some(patched_auth_json) = state.patched_auth_json.as_deref()
301        && auth_json_matches_helper_patch(current_text.as_deref(), patched_auth_json)
302    {
303        return (
304            state.original_auth_json_absent,
305            state.original_auth_json.clone(),
306        );
307    }
308
309    (current_text.is_none(), current_text)
310}
311
312fn switch_off_codex_toml(
313    current_text: &str,
314    original: &CodexSwitchState,
315) -> Result<CodexSwitchOffEdit> {
316    let mut doc = if current_text.trim().is_empty() {
317        EditableTomlDocument::new()
318    } else {
319        current_text.parse::<EditableTomlDocument>()?
320    };
321    let root = doc.as_table_mut();
322
323    let current_model_provider = toml_string(root, "model_provider");
324    let proxy_is_helper = root
325        .get("model_providers")
326        .and_then(EditableTomlItem::as_table)
327        .and_then(|table| table.get("codex_proxy"))
328        .map(|item| local_helper_proxy_item(Some(item)))
329        .unwrap_or(current_model_provider.as_deref() == Some("codex_proxy"));
330
331    if current_model_provider.as_deref() == Some("codex_proxy") && proxy_is_helper {
332        if let Some(provider) = original.original_model_provider.as_deref() {
333            set_toml_string(root, "model_provider", provider);
334        } else {
335            root.remove("model_provider");
336        }
337    }
338
339    let mut remove_model_providers = false;
340    if let Some(providers_table) = root
341        .get_mut("model_providers")
342        .and_then(EditableTomlItem::as_table_mut)
343    {
344        let proxy_is_helper = local_helper_proxy_item(providers_table.get("codex_proxy"));
345        if proxy_is_helper {
346            if let Some(original_proxy) = original.original_codex_proxy.as_ref() {
347                providers_table.insert(
348                    "codex_proxy",
349                    editable_item_from_toml_value(original_proxy)?,
350                );
351            } else {
352                providers_table.remove("codex_proxy");
353            }
354        }
355        remove_model_providers = !original.had_model_providers && providers_table.is_empty();
356    }
357    if remove_model_providers {
358        root.remove("model_providers");
359    }
360
361    if original.original_config_absent && root.is_empty() {
362        Ok(CodexSwitchOffEdit::RemoveFile)
363    } else {
364        Ok(CodexSwitchOffEdit::Write(doc.to_string()))
365    }
366}
367
368fn codex_config_text_with_switch_state(
369    current_text: &str,
370    state: &CodexSwitchState,
371) -> Result<String> {
372    let mut doc = if current_text.trim().is_empty() {
373        EditableTomlDocument::new()
374    } else {
375        current_text.parse::<EditableTomlDocument>()?
376    };
377    let root = doc.as_table_mut();
378    let current_model_provider = toml_string(root, "model_provider");
379    let proxy_is_helper = root
380        .get("model_providers")
381        .and_then(EditableTomlItem::as_table)
382        .and_then(|table| table.get("codex_proxy"))
383        .map(|item| local_helper_proxy_item(Some(item)))
384        .unwrap_or(current_model_provider.as_deref() == Some("codex_proxy"));
385
386    if current_model_provider.as_deref() != Some("codex_proxy") || !proxy_is_helper {
387        return Ok(current_text.to_string());
388    }
389
390    if let Some(provider) = state.original_model_provider.as_deref() {
391        set_toml_string(root, "model_provider", provider);
392    } else {
393        root.remove("model_provider");
394    }
395
396    let mut remove_model_providers = false;
397    if let Some(providers_table) = root
398        .get_mut("model_providers")
399        .and_then(EditableTomlItem::as_table_mut)
400    {
401        if let Some(original_proxy) = state.original_codex_proxy.as_ref() {
402            providers_table.insert(
403                "codex_proxy",
404                editable_item_from_toml_value(original_proxy)?,
405            );
406        } else {
407            providers_table.remove("codex_proxy");
408        }
409        remove_model_providers = !state.had_model_providers && providers_table.is_empty();
410    }
411    if remove_model_providers {
412        root.remove("model_providers");
413    }
414
415    Ok(doc.to_string())
416}
417
418pub fn codex_config_text_for_import() -> Result<Option<String>> {
419    let cfg_path = codex_config_path();
420    if !cfg_path.exists() {
421        return Ok(None);
422    }
423    let current_text = read_config_text(&cfg_path)?;
424    let Some(state) = read_codex_switch_state()? else {
425        return Ok(Some(current_text));
426    };
427    codex_config_text_with_switch_state(&current_text, &state).map(Some)
428}
429
430#[cfg(test)]
431fn switch_on_codex_toml_with_mode(text: &str, port: u16, mode: CodexPatchMode) -> Result<String> {
432    switch_on_codex_toml_with_options(text, port, mode, CodexSwitchOptions::default())
433}
434
435#[cfg(test)]
436fn switch_on_codex_toml_with_options(
437    text: &str,
438    port: u16,
439    mode: CodexPatchMode,
440    options: CodexSwitchOptions,
441) -> Result<String> {
442    let plan = CodexPatchPlan::for_switch_on(mode, options)?;
443    switch_on_codex_toml_with_plan(text, port, plan)
444}
445
446#[cfg(test)]
447fn switch_on_codex_toml_with_plan(text: &str, port: u16, plan: CodexPatchPlan) -> Result<String> {
448    switch_on_codex_toml_with_base_url_and_plan(
449        text,
450        &format!("http://127.0.0.1:{port}"),
451        plan,
452        CodexHostedImageGenerationMode::Auto,
453    )
454}
455
456#[cfg(test)]
457fn switch_on_codex_toml_with_plan_and_hosted_image_generation(
458    text: &str,
459    port: u16,
460    plan: CodexPatchPlan,
461    hosted_image_generation: CodexHostedImageGenerationMode,
462) -> Result<String> {
463    switch_on_codex_toml_with_base_url_and_plan(
464        text,
465        &format!("http://127.0.0.1:{port}"),
466        plan,
467        hosted_image_generation,
468    )
469}
470
471fn apply_feature_bool_patch(
472    root: &mut EditableTomlTable,
473    key: &'static str,
474    patch: CodexFeatureBoolPatch,
475) -> Result<()> {
476    let CodexFeatureBoolPatch::Set(value) = patch else {
477        return Ok(());
478    };
479
480    if !root.contains_key("features") {
481        root.insert(
482            "features",
483            EditableTomlItem::Table(EditableTomlTable::new()),
484        );
485    }
486    let features = root
487        .get_mut("features")
488        .and_then(EditableTomlItem::as_table_mut)
489        .ok_or_else(|| anyhow!("features must be a table"))?;
490    features.insert(key, editable_toml_value(value));
491    Ok(())
492}
493
494fn switch_on_codex_toml_with_base_url_and_plan(
495    text: &str,
496    base_url: &str,
497    plan: CodexPatchPlan,
498    hosted_image_generation: CodexHostedImageGenerationMode,
499) -> Result<String> {
500    let mut doc = if text.trim().is_empty() {
501        EditableTomlDocument::new()
502    } else {
503        text.parse::<EditableTomlDocument>()?
504    };
505    let root = doc.as_table_mut();
506
507    if !root.contains_key("model_providers") {
508        root.insert(
509            "model_providers",
510            EditableTomlItem::Table(EditableTomlTable::new()),
511        );
512    }
513    let providers_table = root
514        .get_mut("model_providers")
515        .and_then(EditableTomlItem::as_table_mut)
516        .ok_or_else(|| anyhow!("model_providers must be a table"))?;
517
518    if !providers_table.contains_key("codex_proxy") {
519        providers_table.insert(
520            "codex_proxy",
521            EditableTomlItem::Table(EditableTomlTable::new()),
522        );
523    }
524    let proxy_table = providers_table
525        .get_mut("codex_proxy")
526        .and_then(EditableTomlItem::as_table_mut)
527        .ok_or_else(|| anyhow!("model_providers.codex_proxy must be a table"))?;
528
529    let provider = plan.provider();
530    set_toml_string(proxy_table, "name", provider.provider_name());
531    set_toml_string(proxy_table, "base_url", base_url);
532    set_toml_string(proxy_table, "wire_api", "responses");
533    if !proxy_table.contains_key("request_max_retries") {
534        proxy_table.insert("request_max_retries", editable_toml_value(0));
535    }
536    match provider.requires_openai_auth() {
537        CodexTomlBoolPatch::Remove => {
538            proxy_table.remove("requires_openai_auth");
539        }
540        CodexTomlBoolPatch::Set(value) => {
541            proxy_table.insert("requires_openai_auth", editable_toml_value(value));
542        }
543    }
544    match provider.supports_websockets() {
545        CodexTomlBoolPatch::Remove => {
546            proxy_table.remove("supports_websockets");
547        }
548        CodexTomlBoolPatch::Set(value) => {
549            proxy_table.insert("supports_websockets", editable_toml_value(value));
550        }
551    }
552    apply_feature_bool_patch(
553        root,
554        "remote_compaction_v2",
555        plan.remote_compaction_v2_feature(),
556    )?;
557    apply_feature_bool_patch(
558        root,
559        "image_generation",
560        hosted_image_generation.feature_patch(),
561    )?;
562
563    set_toml_string(root, "model_provider", "codex_proxy");
564    Ok(doc.to_string())
565}
566
567fn ensure_codex_remote_connections_feature_in_toml(text: &str) -> Result<String> {
568    let mut doc = if text.trim().is_empty() {
569        EditableTomlDocument::new()
570    } else {
571        text.parse::<EditableTomlDocument>()?
572    };
573    let root = doc.as_table_mut();
574
575    if !root.contains_key("features") {
576        root.insert(
577            "features",
578            EditableTomlItem::Table(EditableTomlTable::new()),
579        );
580    }
581    let features = root
582        .get_mut("features")
583        .and_then(EditableTomlItem::as_table_mut)
584        .ok_or_else(|| anyhow!("features must be a table"))?;
585    features.insert("remote_connections", editable_toml_value(true));
586    features.remove("remote_control");
587
588    Ok(doc.to_string())
589}
590
591fn codex_remote_connections_feature_enabled_from_toml(text: &str) -> Result<bool> {
592    if text.trim().is_empty() {
593        return Ok(false);
594    }
595    let value = text.parse::<Value>()?;
596    Ok(value
597        .as_table()
598        .and_then(|root| root.get("features"))
599        .and_then(Value::as_table)
600        .and_then(|features| features.get("remote_connections"))
601        .and_then(Value::as_bool)
602        .unwrap_or(false))
603}
604
605fn codex_remote_control_feature_present_in_toml(text: &str) -> Result<bool> {
606    if text.trim().is_empty() {
607        return Ok(false);
608    }
609    let value = text.parse::<Value>()?;
610    Ok(value
611        .as_table()
612        .and_then(|root| root.get("features"))
613        .and_then(Value::as_table)
614        .is_some_and(|features| features.contains_key("remote_control")))
615}
616
617fn codex_remote_compaction_v2_feature_enabled_from_toml(text: &str) -> Result<bool> {
618    if text.trim().is_empty() {
619        return Ok(false);
620    }
621    let value = text.parse::<Value>()?;
622    Ok(value
623        .as_table()
624        .and_then(|root| root.get("features"))
625        .and_then(Value::as_table)
626        .and_then(|features| features.get("remote_compaction_v2"))
627        .and_then(Value::as_bool)
628        .unwrap_or(false))
629}
630
631fn json_string_at_path<'a>(value: &'a serde_json::Value, path: &[&str]) -> Option<&'a str> {
632    let mut current = value;
633    for key in path {
634        current = current.get(*key)?;
635    }
636    current.as_str().filter(|text| !text.trim().is_empty())
637}
638
639fn decode_jwt_payload(jwt: &str) -> Result<serde_json::Value> {
640    use base64::Engine as _;
641
642    let mut parts = jwt.split('.');
643    let (_header, payload, _signature) = match (parts.next(), parts.next(), parts.next()) {
644        (Some(header), Some(payload), Some(signature))
645            if !header.is_empty() && !payload.is_empty() && !signature.is_empty() =>
646        {
647            (header, payload, signature)
648        }
649        _ => return Err(anyhow!("invalid JWT format")),
650    };
651    let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
652        .decode(payload)
653        .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(payload))
654        .context("decode JWT payload")?;
655    serde_json::from_slice(&bytes).context("parse JWT payload JSON")
656}
657
658fn chatgpt_bridge_auth_requirements_missing(
659    value: &serde_json::Value,
660) -> Result<Vec<&'static str>> {
661    let obj = value
662        .as_object()
663        .ok_or_else(|| anyhow!("Codex auth.json root must be a JSON object"))?;
664    let tokens = obj.get("tokens").and_then(serde_json::Value::as_object);
665    let mut missing = Vec::new();
666
667    let id_token = match tokens.and_then(|tokens| tokens.get("id_token")) {
668        Some(value) => value.as_str().filter(|text| !text.trim().is_empty()),
669        None => None,
670    };
671    let id_token_payload = match id_token {
672        Some(id_token) => Some(decode_jwt_payload(id_token).context("decode tokens.id_token")?),
673        None => {
674            missing.push("tokens.id_token");
675            None
676        }
677    };
678
679    if tokens
680        .and_then(|tokens| tokens.get("access_token"))
681        .and_then(serde_json::Value::as_str)
682        .is_none_or(|text| text.trim().is_empty())
683    {
684        missing.push("tokens.access_token");
685    }
686    if tokens
687        .and_then(|tokens| tokens.get("refresh_token"))
688        .and_then(serde_json::Value::as_str)
689        .is_none_or(|text| text.trim().is_empty())
690    {
691        missing.push("tokens.refresh_token");
692    }
693    if obj
694        .get("last_refresh")
695        .is_none_or(serde_json::Value::is_null)
696    {
697        missing.push("last_refresh");
698    }
699
700    if let Some(payload) = id_token_payload.as_ref() {
701        let has_email = json_string_at_path(payload, &["email"])
702            .or_else(|| json_string_at_path(payload, &["https://api.openai.com/profile", "email"]))
703            .is_some();
704        if !has_email {
705            missing.push("tokens.id_token.email");
706        }
707
708        let has_account_id = tokens
709            .and_then(|tokens| tokens.get("account_id"))
710            .and_then(serde_json::Value::as_str)
711            .is_some_and(|text| !text.trim().is_empty())
712            || json_string_at_path(
713                payload,
714                &["https://api.openai.com/auth", "chatgpt_account_id"],
715            )
716            .is_some();
717        if !has_account_id {
718            missing.push("tokens.account_id or tokens.id_token.chatgpt_account_id");
719        }
720    }
721
722    Ok(missing)
723}
724
725fn ensure_chatgpt_bridge_auth_ready(value: &serde_json::Value) -> Result<()> {
726    let missing = chatgpt_bridge_auth_requirements_missing(value)?;
727    if missing.is_empty() {
728        return Ok(());
729    }
730
731    Err(anyhow!(
732        "Codex auth.json does not contain a complete ChatGPT login state required for chatgpt-bridge (missing: {}). Open Codex and sign in with ChatGPT first, then run `codex-helper switch on --mode chatgpt-bridge` again.",
733        missing.join(", ")
734    ))
735}
736
737fn chatgpt_bridge_auth_json_value(mut value: serde_json::Value) -> Result<serde_json::Value> {
738    ensure_chatgpt_bridge_auth_ready(&value)?;
739    let obj = value
740        .as_object_mut()
741        .ok_or_else(|| anyhow!("Codex auth.json root must be a JSON object"))?;
742    obj.insert(
743        "auth_mode".to_string(),
744        serde_json::Value::String("chatgpt".to_string()),
745    );
746    obj.insert("OPENAI_API_KEY".to_string(), serde_json::Value::Null);
747    Ok(value)
748}
749
750fn chatgpt_bridge_auth_json_text(text: &str) -> Result<String> {
751    let mut value: serde_json::Value =
752        serde_json::from_str(text).context("parse Codex auth.json as JSON")?;
753    value = chatgpt_bridge_auth_json_value(value)?;
754    Ok(serde_json::to_string_pretty(&value)?)
755}
756
757fn imagegen_bridge_auth_json_text() -> Result<String> {
758    Ok(serde_json::to_string_pretty(&serde_json::json!({}))?)
759}
760
761fn auth_json_is_empty_chatgpt_facade_text(text: Option<&str>) -> bool {
762    let Some(text) = text else {
763        return false;
764    };
765    serde_json::from_str::<serde_json::Value>(text)
766        .ok()
767        .and_then(|value| value.as_object().cloned())
768        .is_some_and(|object| object.is_empty())
769}
770
771fn current_auth_json_is_empty_chatgpt_facade() -> bool {
772    let path = codex_auth_path();
773    if !path.exists() {
774        return false;
775    }
776    let Ok(text) = read_config_text(&path) else {
777        return false;
778    };
779    auth_json_is_empty_chatgpt_facade_text(Some(&text))
780}
781
782fn current_auth_json_facade_state() -> Result<Option<bool>> {
783    let path = codex_auth_path();
784    if !path.exists() {
785        return Ok(None);
786    }
787    let text = read_config_text(&path).with_context(|| format!("read {:?}", path))?;
788    Ok(Some(auth_json_is_empty_chatgpt_facade_text(Some(&text))))
789}
790
791fn prepare_chatgpt_bridge_auth_patch_from_baseline(
792    original_absent: bool,
793    original_text: Option<String>,
794) -> Result<CodexAuthPatch> {
795    let auth_path = codex_auth_path();
796    let Some(original_text) = original_text else {
797        return Err(anyhow!(
798            "Codex auth.json not found at {:?}; run `codex login` first, then enable chatgpt-bridge preset.",
799            auth_path
800        ));
801    };
802    if original_absent {
803        return Err(anyhow!(
804            "Codex auth.json not found at {:?}; run `codex login` first, then enable chatgpt-bridge preset.",
805            auth_path
806        ));
807    }
808    let patched_text = chatgpt_bridge_auth_json_text(&original_text)?;
809    Ok(CodexAuthPatch {
810        original_absent: false,
811        original_text: Some(original_text),
812        patched_text,
813    })
814}
815
816fn prepare_imagegen_bridge_auth_patch_from_baseline(
817    original_absent: bool,
818    original_text: Option<String>,
819) -> Result<CodexAuthPatch> {
820    Ok(CodexAuthPatch {
821        original_absent,
822        original_text,
823        patched_text: imagegen_bridge_auth_json_text()?,
824    })
825}
826
827fn auth_edit_for_switch_on_plan(
828    plan: CodexPatchPlan,
829    state: &mut CodexSwitchState,
830) -> Result<CodexAuthEdit> {
831    match plan.auth() {
832        CodexAuthPatchPlan::RestoreOriginalIfHelperPatched => auth_restore_edit_from_state(state),
833        CodexAuthPatchPlan::PatchChatGptBridge | CodexAuthPatchPlan::PatchImagegenFacade => {
834            let current_auth = read_current_codex_auth_text()?;
835            let (original_absent, original_text) =
836                auth_baseline_for_patch_from_current(state, current_auth.clone());
837            let patch = match plan.auth() {
838                CodexAuthPatchPlan::PatchChatGptBridge => {
839                    prepare_chatgpt_bridge_auth_patch_from_baseline(original_absent, original_text)?
840                }
841                CodexAuthPatchPlan::PatchImagegenFacade => {
842                    prepare_imagegen_bridge_auth_patch_from_baseline(
843                        original_absent,
844                        original_text,
845                    )?
846                }
847                CodexAuthPatchPlan::RestoreOriginalIfHelperPatched => {
848                    unreachable!("handled above")
849                }
850            };
851            let auth_edit = if auth_json_matches_helper_patch(
852                current_auth.as_deref(),
853                patch.patched_text.as_str(),
854            ) {
855                CodexAuthEdit::None
856            } else {
857                CodexAuthEdit::Write(patch.patched_text.clone())
858            };
859            state.set_auth_patch(&patch);
860            Ok(auth_edit)
861        }
862    }
863}
864
865fn apply_switch_on_effects(
866    plan: CodexPatchPlan,
867    cfg_path: &Path,
868    new_text: &str,
869    state: &CodexSwitchState,
870    auth_edit: CodexAuthEdit,
871) -> Result<()> {
872    match plan.effect_order() {
873        CodexSwitchOnEffectOrder::ConfigAuthState => {
874            atomic_write(cfg_path, new_text)?;
875            apply_codex_auth_edit(auth_edit)?;
876            write_codex_switch_state(state)?;
877        }
878        CodexSwitchOnEffectOrder::StateConfigAuth => {
879            write_codex_switch_state(state)?;
880            atomic_write(cfg_path, new_text)?;
881            apply_codex_auth_edit(auth_edit)?;
882        }
883    }
884    Ok(())
885}
886
887fn auth_env_is_set(env_name: &str) -> bool {
888    let env_name = env_name.trim();
889    !env_name.is_empty() && std::env::var(env_name).is_ok_and(|value| !value.trim().is_empty())
890}
891
892fn upstream_auth_has_resolved_credential(auth: &crate::config::UpstreamAuth) -> bool {
893    auth.auth_token
894        .as_deref()
895        .is_some_and(|token| !token.trim().is_empty())
896        || auth
897            .api_key
898            .as_deref()
899            .is_some_and(|key| !key.trim().is_empty())
900        || auth.auth_token_env.as_deref().is_some_and(auth_env_is_set)
901        || auth.api_key_env.as_deref().is_some_and(auth_env_is_set)
902}
903
904fn upstream_has_resolved_auth(upstream: &crate::config::UpstreamConfig) -> bool {
905    upstream_auth_has_resolved_credential(&upstream.auth)
906}
907
908fn upstream_auth_env_names(upstream: &crate::config::UpstreamConfig) -> impl Iterator<Item = &str> {
909    upstream
910        .auth
911        .auth_token_env
912        .as_deref()
913        .into_iter()
914        .chain(upstream.auth.api_key_env.as_deref())
915        .map(str::trim)
916        .filter(|value| !value.is_empty())
917}
918
919fn config_toml_schema_version_or_shape(text: &str) -> Option<u32> {
920    let value = toml::from_str::<toml::Value>(text).ok()?;
921    if let Some(version) = value
922        .get("version")
923        .and_then(|value| value.as_integer())
924        .map(|value| value as u32)
925    {
926        return Some(version);
927    }
928
929    let has_v4_routing = ["codex", "claude"].iter().any(|service| {
930        value
931            .get(*service)
932            .and_then(|service| service.get("routing"))
933            .and_then(|routing| routing.get("entry").or_else(|| routing.get("routes")))
934            .is_some()
935    });
936    if has_v4_routing {
937        return Some(4);
938    }
939
940    let has_legacy_routing = ["codex", "claude"].iter().any(|service| {
941        value
942            .get(*service)
943            .and_then(|service| service.get("routing"))
944            .is_some()
945    });
946    if has_legacy_routing { Some(3) } else { None }
947}
948
949fn load_runtime_config_for_bridge_check() -> Result<ProxyConfig> {
950    let path = crate::config::config_file_path();
951    if !path.exists() {
952        return Ok(ProxyConfig::default());
953    }
954
955    let text = read_config_text(&path).with_context(|| format!("read {:?}", path))?;
956    if path.extension().and_then(|ext| ext.to_str()) == Some("toml") {
957        let version = config_toml_schema_version_or_shape(&text);
958        if version.is_some_and(crate::config::is_supported_route_graph_config_version) {
959            let cfg = toml::from_str::<ProxyConfigV4>(&text)
960                .with_context(|| format!("parse {:?} as route graph config", path))?;
961            return crate::config::compile_v4_to_runtime(&cfg);
962        }
963        if version == Some(3) {
964            let cfg = toml::from_str::<crate::config::legacy::ProxyConfigV3Legacy>(&text)
965                .with_context(|| format!("parse {:?} as legacy route config", path))?;
966            let migrated = crate::config::legacy::migrate_v3_legacy_to_v4(&cfg)?;
967            return crate::config::compile_v4_to_runtime(&migrated.config);
968        }
969        if version == Some(2) {
970            let cfg = toml::from_str::<ProxyConfigV2>(&text)
971                .with_context(|| format!("parse {:?} as v2 config", path))?;
972            return crate::config::compile_v2_to_runtime(&cfg);
973        }
974        return toml::from_str::<ProxyConfig>(&text)
975            .with_context(|| format!("parse {:?} as runtime config", path));
976    }
977
978    serde_json::from_str::<ProxyConfig>(&text).with_context(|| format!("parse {:?}", path))
979}
980
981#[derive(Debug, Clone, Default, PartialEq, Eq)]
982pub struct CodexBridgeRuntimeAuthSnapshot {
983    pub routable_upstreams: usize,
984    pub authed_upstreams: usize,
985    pub missing_env: Vec<String>,
986}
987
988fn codex_bridge_runtime_auth_snapshot_from_config(
989    cfg: &ProxyConfig,
990) -> CodexBridgeRuntimeAuthSnapshot {
991    let mut snapshot = CodexBridgeRuntimeAuthSnapshot::default();
992    let mut missing_env = BTreeSet::new();
993    for station in cfg
994        .codex
995        .stations()
996        .values()
997        .filter(|station| station.enabled)
998    {
999        for upstream in &station.upstreams {
1000            snapshot.routable_upstreams += 1;
1001            if upstream_has_resolved_auth(upstream) {
1002                snapshot.authed_upstreams += 1;
1003            } else {
1004                for env_name in upstream_auth_env_names(upstream) {
1005                    missing_env.insert(env_name.to_string());
1006                }
1007            }
1008        }
1009    }
1010    snapshot.missing_env = missing_env.into_iter().collect();
1011    snapshot
1012}
1013
1014pub fn codex_bridge_runtime_auth_snapshot() -> Result<CodexBridgeRuntimeAuthSnapshot> {
1015    let cfg = load_runtime_config_for_bridge_check()
1016        .context("load codex-helper config for bridge diagnostics")?;
1017    Ok(codex_bridge_runtime_auth_snapshot_from_config(&cfg))
1018}
1019
1020fn ensure_bridge_runtime_ready(mode: CodexPatchMode) -> Result<()> {
1021    let cfg = load_runtime_config_for_bridge_check().with_context(|| {
1022        format!(
1023            "load codex-helper config before enabling {}",
1024            mode.as_preset_str()
1025        )
1026    })?;
1027    let snapshot = codex_bridge_runtime_auth_snapshot_from_config(&cfg);
1028
1029    if snapshot.routable_upstreams == 0 {
1030        anyhow::bail!(
1031            "{} requires at least one enabled Codex upstream in codex-helper config; run `codex-helper config init` or add a [codex.providers.*] entry first",
1032            mode.as_preset_str()
1033        );
1034    }
1035    if snapshot.authed_upstreams == 0 {
1036        if snapshot.missing_env.is_empty() {
1037            anyhow::bail!(
1038                "{} strips Codex client auth, but no enabled Codex upstream has auth_token/auth_token_env/api_key/api_key_env configured; configure an upstream credential before enabling it",
1039                mode.as_preset_str()
1040            );
1041        }
1042        anyhow::bail!(
1043            "{} strips Codex client auth, but no enabled Codex upstream credential is available in this process; set one of these env vars first: {}",
1044            mode.as_preset_str(),
1045            snapshot.missing_env.join(", ")
1046        );
1047    }
1048
1049    Ok(())
1050}
1051
1052#[cfg(test)]
1053fn ensure_imagegen_bridge_runtime_ready() -> Result<()> {
1054    ensure_bridge_runtime_ready(CodexPatchMode::ImagegenBridge)
1055}
1056
1057pub fn patch_codex_auth_for_chatgpt_bridge() -> Result<()> {
1058    let auth_path = codex_auth_path();
1059    if !auth_path.exists() {
1060        return Err(anyhow!(
1061            "Codex auth.json not found at {:?}; run `codex login` first, then enable chatgpt-bridge preset.",
1062            auth_path
1063        ));
1064    }
1065    let text = read_config_text(&auth_path)?;
1066    let new_text = chatgpt_bridge_auth_json_text(&text)?;
1067    atomic_write(&auth_path, &new_text)?;
1068    Ok(())
1069}
1070
1071#[derive(Debug, Clone)]
1072pub struct CodexSwitchStatus {
1073    /// Whether Codex currently appears to be configured to use the local codex-helper proxy.
1074    pub enabled: bool,
1075    /// Current `model_provider` value (if any).
1076    pub model_provider: Option<String>,
1077    /// Current `model_providers.codex_proxy.name` value (if any).
1078    pub provider_name: Option<String>,
1079    /// Current `model_providers.codex_proxy.base_url` (if any).
1080    pub base_url: Option<String>,
1081    /// Current codex-helper Codex preset inferred from `model_providers.codex_proxy`.
1082    pub patch_mode: Option<CodexPatchMode>,
1083    /// Current `model_providers.codex_proxy.requires_openai_auth` value (if any).
1084    pub requires_openai_auth: Option<bool>,
1085    /// Current `model_providers.codex_proxy.supports_websockets` value (if any).
1086    pub supports_websockets: Option<bool>,
1087    /// Whether `[features].remote_compaction_v2 = true` is present in Codex config.
1088    pub remote_compaction_v2_enabled: bool,
1089    /// Stored compaction strategy when switch state is present; otherwise inferred from the
1090    /// current Codex client patch.
1091    pub compaction_strategy: CodexCompactionStrategy,
1092    /// Whether original switch metadata exists for disabling the local proxy patch.
1093    pub has_switch_state: bool,
1094}
1095
1096#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
1097#[serde(rename_all = "snake_case")]
1098pub enum CodexBridgeDiagnosticStatus {
1099    Ok,
1100    Info,
1101    Warn,
1102    Fail,
1103}
1104
1105impl CodexBridgeDiagnosticStatus {
1106    pub fn as_str(self) -> &'static str {
1107        match self {
1108            Self::Ok => "ok",
1109            Self::Info => "info",
1110            Self::Warn => "warn",
1111            Self::Fail => "fail",
1112        }
1113    }
1114}
1115
1116#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1117pub struct CodexBridgeDiagnosticCheck {
1118    pub id: &'static str,
1119    pub status: CodexBridgeDiagnosticStatus,
1120    pub message: String,
1121    #[serde(skip_serializing_if = "Option::is_none")]
1122    pub action: Option<String>,
1123}
1124
1125#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
1126pub struct CodexBridgeDiagnostics {
1127    pub patch_mode: Option<CodexPatchMode>,
1128    pub enabled: bool,
1129    pub remote_compaction_v1_ready: bool,
1130    pub imagegen_facade_ready: bool,
1131    pub upstream_auth_ready: bool,
1132    pub remote_compaction_v2_enabled: bool,
1133    pub checks: Vec<CodexBridgeDiagnosticCheck>,
1134}
1135
1136impl CodexBridgeDiagnostics {
1137    pub fn worst_status(&self) -> CodexBridgeDiagnosticStatus {
1138        if self
1139            .checks
1140            .iter()
1141            .any(|check| check.status == CodexBridgeDiagnosticStatus::Fail)
1142        {
1143            return CodexBridgeDiagnosticStatus::Fail;
1144        }
1145        if self
1146            .checks
1147            .iter()
1148            .any(|check| check.status == CodexBridgeDiagnosticStatus::Warn)
1149        {
1150            return CodexBridgeDiagnosticStatus::Warn;
1151        }
1152        if self
1153            .checks
1154            .iter()
1155            .any(|check| check.status == CodexBridgeDiagnosticStatus::Info)
1156        {
1157            return CodexBridgeDiagnosticStatus::Info;
1158        }
1159        CodexBridgeDiagnosticStatus::Ok
1160    }
1161}
1162
1163fn push_bridge_check(
1164    checks: &mut Vec<CodexBridgeDiagnosticCheck>,
1165    id: &'static str,
1166    status: CodexBridgeDiagnosticStatus,
1167    message: impl Into<String>,
1168    action: Option<String>,
1169) {
1170    checks.push(CodexBridgeDiagnosticCheck {
1171        id,
1172        status,
1173        message: message.into(),
1174        action,
1175    });
1176}
1177
1178fn bridge_mode_expects_imagegen_facade(mode: Option<CodexPatchMode>) -> bool {
1179    mode.is_some_and(CodexPatchMode::enables_imagegen_facade)
1180}
1181
1182pub fn codex_bridge_diagnostics() -> CodexBridgeDiagnostics {
1183    let status_result = codex_switch_status();
1184    let mut checks = Vec::new();
1185    let mut enabled = false;
1186    let mut patch_mode = None;
1187    let mut remote_compaction_v2_enabled = false;
1188    let mut remote_compaction_v1_ready = false;
1189    let mut imagegen_facade_ready = false;
1190
1191    match status_result {
1192        Ok(status) => {
1193            enabled = status.enabled;
1194            patch_mode = status.patch_mode;
1195            remote_compaction_v2_enabled = status.remote_compaction_v2_enabled;
1196
1197            if !status.enabled {
1198                push_bridge_check(
1199                    &mut checks,
1200                    "codex_bridge.switch",
1201                    CodexBridgeDiagnosticStatus::Info,
1202                    format!(
1203                        "Codex is not currently routed through codex-helper (model_provider={}).",
1204                        status.model_provider.as_deref().unwrap_or("<unset>")
1205                    ),
1206                    Some(
1207                        "Run `codex-helper switch on --preset official-imagegen` after starting helper if you want relay + remote compact + imagegen.".to_string(),
1208                    ),
1209                );
1210            } else {
1211                push_bridge_check(
1212                    &mut checks,
1213                    "codex_bridge.switch",
1214                    CodexBridgeDiagnosticStatus::Ok,
1215                    format!(
1216                        "Codex is routed through codex-helper on {} with preset={}.",
1217                        status.base_url.as_deref().unwrap_or("<missing base_url>"),
1218                        patch_mode
1219                            .map(|mode| mode.as_preset_str())
1220                            .unwrap_or("<unknown>")
1221                    ),
1222                    None,
1223                );
1224            }
1225
1226            let remote_compaction_identity = status.provider_name.as_deref() == Some("OpenAI");
1227            if remote_compaction_identity {
1228                let provider_ok = status.provider_name.as_deref() == Some("OpenAI");
1229                remote_compaction_v1_ready = status.enabled && provider_ok;
1230                let status_label = if remote_compaction_v1_ready {
1231                    CodexBridgeDiagnosticStatus::Ok
1232                } else {
1233                    CodexBridgeDiagnosticStatus::Fail
1234                };
1235                push_bridge_check(
1236                    &mut checks,
1237                    "codex_bridge.remote_compaction_v1",
1238                    status_label,
1239                    format!(
1240                        "Remote compaction v1 requires provider name OpenAI; current name={}, supports_websockets={}.",
1241                        status.provider_name.as_deref().unwrap_or("<missing>"),
1242                        status
1243                            .supports_websockets
1244                            .map(|value| value.to_string())
1245                            .unwrap_or_else(|| "<missing>".to_string())
1246                    ),
1247                    (!remote_compaction_v1_ready).then(|| {
1248                        "Run `codex-helper switch on --preset official-imagegen` or `--preset official-relay`, then fully restart Codex clients.".to_string()
1249                    }),
1250                );
1251            } else {
1252                push_bridge_check(
1253                    &mut checks,
1254                    "codex_bridge.remote_compaction_v1",
1255                    CodexBridgeDiagnosticStatus::Info,
1256                    format!(
1257                        "Current compaction strategy {} does not advertise the local relay as the official OpenAI provider.",
1258                        status.compaction_strategy
1259                    ),
1260                    Some(
1261                        "Use `--compaction remote-v1` or `--compaction remote-v2` with an official preset when you need Codex remote compaction through relay.".to_string(),
1262                    ),
1263                );
1264            }
1265
1266            if status.supports_websockets == Some(true) {
1267                let provider_ok = status.provider_name.as_deref() == Some("OpenAI");
1268                let ws_ready = status.enabled && provider_ok;
1269                let status_label = if ws_ready {
1270                    CodexBridgeDiagnosticStatus::Ok
1271                } else {
1272                    CodexBridgeDiagnosticStatus::Fail
1273                };
1274                push_bridge_check(
1275                    &mut checks,
1276                    "codex_bridge.responses_websocket",
1277                    status_label,
1278                    format!(
1279                        "Responses WebSocket requires provider name OpenAI and supports_websockets=true; current name={}, supports_websockets={}.",
1280                        status.provider_name.as_deref().unwrap_or("<missing>"),
1281                        status
1282                            .supports_websockets
1283                            .map(|value| value.to_string())
1284                            .unwrap_or_else(|| "<missing>".to_string())
1285                    ),
1286                    (!ws_ready).then(|| {
1287                        "Run `codex-helper switch on --preset official-relay --responses-websocket` or `--preset official-imagegen --responses-websocket`, then fully restart Codex clients.".to_string()
1288                    }),
1289                );
1290            } else {
1291                let ws_status =
1292                    if patch_mode.is_some_and(CodexPatchMode::enables_official_relay_features) {
1293                        CodexBridgeDiagnosticStatus::Ok
1294                    } else {
1295                        CodexBridgeDiagnosticStatus::Info
1296                    };
1297                push_bridge_check(
1298                    &mut checks,
1299                    "codex_bridge.responses_websocket",
1300                    ws_status,
1301                    "Current preset does not advertise Responses WebSocket transport."
1302                        .to_string(),
1303                    Some(
1304                        "Use `--responses-websocket` only when helper and the selected relay both support Responses WebSocket v2.".to_string(),
1305                    ),
1306                );
1307            }
1308
1309            if bridge_mode_expects_imagegen_facade(patch_mode) {
1310                match current_auth_json_facade_state() {
1311                    Ok(Some(true)) => {
1312                        imagegen_facade_ready = status.enabled;
1313                        push_bridge_check(
1314                            &mut checks,
1315                            "codex_bridge.imagegen_facade",
1316                            CodexBridgeDiagnosticStatus::Ok,
1317                            "Codex auth.json is the empty ChatGPT facade used to expose hosted image generation.".to_string(),
1318                            None,
1319                        );
1320                    }
1321                    Ok(Some(false)) => {
1322                        push_bridge_check(
1323                            &mut checks,
1324                            "codex_bridge.imagegen_facade",
1325                            CodexBridgeDiagnosticStatus::Fail,
1326                            "Codex auth.json is not the empty ChatGPT facade expected by imagegen bridge preset.".to_string(),
1327                            Some(
1328                                "Run `codex-helper switch on --preset official-imagegen`, then fully restart Codex clients.".to_string(),
1329                            ),
1330                        );
1331                    }
1332                    Ok(None) => {
1333                        push_bridge_check(
1334                            &mut checks,
1335                            "codex_bridge.imagegen_facade",
1336                            CodexBridgeDiagnosticStatus::Fail,
1337                            format!("Codex auth.json is missing at {:?}.", codex_auth_path()),
1338                            Some(
1339                                "Run `codex-helper switch on --preset official-imagegen` so helper can write the temporary facade.".to_string(),
1340                            ),
1341                        );
1342                    }
1343                    Err(err) => {
1344                        push_bridge_check(
1345                            &mut checks,
1346                            "codex_bridge.imagegen_facade",
1347                            CodexBridgeDiagnosticStatus::Warn,
1348                            format!("Could not inspect Codex auth.json facade state: {err}"),
1349                            Some("Inspect ~/.codex/auth.json and rerun `codex-helper switch status`.".to_string()),
1350                        );
1351                    }
1352                }
1353            } else {
1354                push_bridge_check(
1355                    &mut checks,
1356                    "codex_bridge.imagegen_facade",
1357                    CodexBridgeDiagnosticStatus::Info,
1358                    format!(
1359                        "Current preset {} does not install the imagegen auth facade.",
1360                        patch_mode
1361                            .map(|mode| mode.as_preset_str())
1362                            .unwrap_or("<unknown>")
1363                    ),
1364                    Some("Use official-imagegen preset when you need relay + remote compact + hosted image generation.".to_string()),
1365                );
1366            }
1367
1368            if status.remote_compaction_v2_enabled {
1369                let action = "Keep helper's [codex.compaction].remote_v2_downgrade enabled unless you explicitly want raw v2 relay behavior without /responses/compact fallback.".to_string();
1370                push_bridge_check(
1371                    &mut checks,
1372                    "codex_bridge.remote_compaction_v2",
1373                    CodexBridgeDiagnosticStatus::Warn,
1374                    "Codex remote_compaction_v2 is enabled; helper will detect compaction_trigger requests and automatically downgrade to /responses/compact when the relay does not return a valid v2 compact stream.".to_string(),
1375                    Some(action),
1376                );
1377            } else {
1378                push_bridge_check(
1379                    &mut checks,
1380                    "codex_bridge.remote_compaction_v2",
1381                    CodexBridgeDiagnosticStatus::Ok,
1382                    "Codex remote_compaction_v2 is not enabled; remote compaction stays on the v1 /responses/compact path for official bridge presets.".to_string(),
1383                    None,
1384                );
1385            }
1386        }
1387        Err(err) => {
1388            push_bridge_check(
1389                &mut checks,
1390                "codex_bridge.switch",
1391                CodexBridgeDiagnosticStatus::Fail,
1392                format!("Could not inspect Codex switch status: {err}"),
1393                Some(
1394                    "Run `codex-helper switch status` and fix ~/.codex/config.toml parsing issues."
1395                        .to_string(),
1396                ),
1397            );
1398        }
1399    }
1400
1401    let runtime_auth = match codex_bridge_runtime_auth_snapshot() {
1402        Ok(snapshot) => {
1403            let auth_ready = snapshot.authed_upstreams > 0;
1404            let status = if auth_ready {
1405                CodexBridgeDiagnosticStatus::Ok
1406            } else {
1407                CodexBridgeDiagnosticStatus::Fail
1408            };
1409            let action = if auth_ready {
1410                None
1411            } else if snapshot.missing_env.is_empty() {
1412                Some(
1413                    "Configure auth_token/auth_token_env/api_key/api_key_env for an enabled Codex upstream in codex-helper config.".to_string(),
1414                )
1415            } else {
1416                Some(format!(
1417                    "Set one of these env vars before starting codex-helper: {}.",
1418                    snapshot.missing_env.join(", ")
1419                ))
1420            };
1421            push_bridge_check(
1422                &mut checks,
1423                "codex_bridge.upstream_auth",
1424                status,
1425                format!(
1426                    "codex-helper has {} enabled Codex upstream(s), {} with usable credentials in this process.",
1427                    snapshot.routable_upstreams, snapshot.authed_upstreams
1428                ),
1429                action,
1430            );
1431            auth_ready
1432        }
1433        Err(err) => {
1434            push_bridge_check(
1435                &mut checks,
1436                "codex_bridge.upstream_auth",
1437                CodexBridgeDiagnosticStatus::Warn,
1438                format!("Could not inspect codex-helper upstream credentials: {err}"),
1439                Some("Check ~/.codex-helper/config.toml and rerun doctor.".to_string()),
1440            );
1441            false
1442        }
1443    };
1444
1445    CodexBridgeDiagnostics {
1446        patch_mode,
1447        enabled,
1448        remote_compaction_v1_ready,
1449        imagegen_facade_ready,
1450        upstream_auth_ready: runtime_auth,
1451        remote_compaction_v2_enabled,
1452        checks,
1453    }
1454}
1455
1456#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1457pub enum CodexStartupReadinessSeverity {
1458    Info,
1459    Warning,
1460}
1461
1462impl CodexStartupReadinessSeverity {
1463    pub fn label(self) -> &'static str {
1464        match self {
1465            Self::Info => "info",
1466            Self::Warning => "warning",
1467        }
1468    }
1469}
1470
1471#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1472pub enum CodexStartupReadinessIssueKind {
1473    ClientStateChanged,
1474    SwitchFailed,
1475    SwitchDisabled,
1476    SwitchPortMismatch,
1477    PatchModeMismatch,
1478    MissingSwitchState,
1479    RemoteControlRemovedKeyPresent,
1480    RemoteControlIncomplete,
1481    RemoteControlLogUnconfirmed,
1482    OfficialRelayAffinityPolicy,
1483    DiagnosticError,
1484}
1485
1486#[derive(Debug, Clone, PartialEq, Eq)]
1487pub struct CodexStartupReadinessIssue {
1488    pub kind: CodexStartupReadinessIssueKind,
1489    pub severity: CodexStartupReadinessSeverity,
1490    pub title: String,
1491    pub detail: String,
1492    pub action: String,
1493}
1494
1495#[derive(Debug, Clone, Default, PartialEq, Eq)]
1496pub struct CodexStartupReadiness {
1497    pub issues: Vec<CodexStartupReadinessIssue>,
1498}
1499
1500impl CodexStartupReadiness {
1501    pub fn has_issues(&self) -> bool {
1502        !self.issues.is_empty()
1503    }
1504
1505    fn push(
1506        &mut self,
1507        kind: CodexStartupReadinessIssueKind,
1508        severity: CodexStartupReadinessSeverity,
1509        title: impl Into<String>,
1510        detail: impl Into<String>,
1511        action: impl Into<String>,
1512    ) {
1513        self.issues.push(CodexStartupReadinessIssue {
1514            kind,
1515            severity,
1516            title: title.into(),
1517            detail: detail.into(),
1518            action: action.into(),
1519        });
1520    }
1521}
1522
1523#[derive(Debug, Clone)]
1524pub struct CodexStartupReadinessInput {
1525    pub expected_port: u16,
1526    pub expected_patch_mode: CodexPatchMode,
1527    pub expected_responses_websocket: bool,
1528    pub client_state_changed_this_startup: bool,
1529    pub switch_error: Option<String>,
1530}
1531
1532pub fn codex_tui_startup_readiness(input: CodexStartupReadinessInput) -> CodexStartupReadiness {
1533    let mut report = CodexStartupReadiness::default();
1534
1535    if input.client_state_changed_this_startup {
1536        report.push(
1537            CodexStartupReadinessIssueKind::ClientStateChanged,
1538            CodexStartupReadinessSeverity::Warning,
1539            "Codex client config changed on startup",
1540            "codex-helper updated ~/.codex/config.toml or ~/.codex/auth.json for the local bridge.",
1541            "Fully restart any already running Codex App, Codex TUI, or codex exec session so it rereads the client config.",
1542        );
1543    }
1544
1545    if let Some(err) = input
1546        .switch_error
1547        .as_deref()
1548        .filter(|err| !err.trim().is_empty())
1549    {
1550        report.push(
1551            CodexStartupReadinessIssueKind::SwitchFailed,
1552            CodexStartupReadinessSeverity::Warning,
1553            "Codex local proxy patch failed",
1554            err,
1555            "Run `codex-helper switch status` and fix the reported Codex client config issue before relying on the bridge.",
1556        );
1557    }
1558
1559    match codex_switch_status() {
1560        Ok(status) => collect_switch_startup_issues(&mut report, &input, &status),
1561        Err(err) => report.push(
1562            CodexStartupReadinessIssueKind::DiagnosticError,
1563            CodexStartupReadinessSeverity::Warning,
1564            "Could not inspect Codex switch status",
1565            err.to_string(),
1566            "Run `codex-helper switch status` from a normal shell to inspect the client config.",
1567        ),
1568    }
1569
1570    match codex_remote_control_status() {
1571        Ok(status) => collect_remote_control_startup_issues(&mut report, &status),
1572        Err(err) => report.push(
1573            CodexStartupReadinessIssueKind::DiagnosticError,
1574            CodexStartupReadinessSeverity::Warning,
1575            "Could not inspect Codex remote-control status",
1576            err.to_string(),
1577            "Run `codex-helper switch remote-control status` from a normal shell to inspect the desktop state.",
1578        ),
1579    }
1580
1581    report
1582}
1583
1584fn collect_switch_startup_issues(
1585    report: &mut CodexStartupReadiness,
1586    input: &CodexStartupReadinessInput,
1587    status: &CodexSwitchStatus,
1588) {
1589    if !status.enabled {
1590        report.push(
1591            CodexStartupReadinessIssueKind::SwitchDisabled,
1592            CodexStartupReadinessSeverity::Warning,
1593            "Codex is not using the local helper",
1594            format!(
1595                "Current model_provider is {}.",
1596                status.model_provider.as_deref().unwrap_or("<unset>")
1597            ),
1598            format!(
1599                "Run `codex-helper switch on --port {}` or restart codex-helper so the client patch can be applied.",
1600                input.expected_port
1601            ),
1602        );
1603        return;
1604    }
1605
1606    if !status.has_switch_state {
1607        report.push(
1608            CodexStartupReadinessIssueKind::MissingSwitchState,
1609            CodexStartupReadinessSeverity::Warning,
1610            "Codex local proxy patch has no switch state",
1611            "Codex points at the local helper, but codex-helper cannot find its restore metadata.",
1612            "Inspect ~/.codex/config.toml before running switch-off operations.",
1613        );
1614    }
1615
1616    if !base_url_points_to_expected_local_port(status.base_url.as_deref(), input.expected_port) {
1617        report.push(
1618            CodexStartupReadinessIssueKind::SwitchPortMismatch,
1619            CodexStartupReadinessSeverity::Warning,
1620            "Codex local proxy port does not match this TUI",
1621            format!(
1622                "codex_proxy.base_url is {}; this TUI is serving port {}.",
1623                status.base_url.as_deref().unwrap_or("<missing>"),
1624                input.expected_port
1625            ),
1626            format!(
1627                "Run `codex-helper switch on --port {}` or restart this helper instance on the configured port.",
1628                input.expected_port
1629            ),
1630        );
1631    }
1632
1633    if status.patch_mode != Some(input.expected_patch_mode) {
1634        let actual = status
1635            .patch_mode
1636            .map(|mode| mode.as_preset_str())
1637            .unwrap_or("<unknown>");
1638        report.push(
1639            CodexStartupReadinessIssueKind::PatchModeMismatch,
1640            CodexStartupReadinessSeverity::Warning,
1641            "Codex bridge preset does not match helper config",
1642            format!(
1643                "Expected preset {}, but Codex currently reports {}.",
1644                input.expected_patch_mode.as_preset_str(),
1645                actual
1646            ),
1647            "Run `codex-helper switch status`; if this changed recently, fully restart Codex clients after switching.",
1648        );
1649    }
1650
1651    if status.supports_websockets.unwrap_or(false) != input.expected_responses_websocket {
1652        report.push(
1653            CodexStartupReadinessIssueKind::PatchModeMismatch,
1654            CodexStartupReadinessSeverity::Warning,
1655            "Codex WebSocket transport flag does not match helper config",
1656            format!(
1657                "Expected supports_websockets={}, but Codex currently reports {}.",
1658                input.expected_responses_websocket,
1659                status
1660                    .supports_websockets
1661                    .map(|value| value.to_string())
1662                    .unwrap_or_else(|| "<missing>".to_string())
1663            ),
1664            "Run `codex-helper switch status`; if this changed recently, fully restart Codex clients after switching.",
1665        );
1666    }
1667
1668    if status.provider_name.as_deref() == Some("OpenAI") {
1669        match official_relay_affinity_policy_warning() {
1670            Ok(Some(detail)) => report.push(
1671                CodexStartupReadinessIssueKind::OfficialRelayAffinityPolicy,
1672                CodexStartupReadinessSeverity::Warning,
1673                "Official relay preset can route a session across providers",
1674                detail,
1675                "For the most official-like remote compaction behavior, set [codex.routing].affinity_policy = \"fallback-sticky\" or \"hard\" when using multiple authenticated upstreams.",
1676            ),
1677            Ok(None) => {}
1678            Err(err) => report.push(
1679                CodexStartupReadinessIssueKind::DiagnosticError,
1680                CodexStartupReadinessSeverity::Warning,
1681                "Could not inspect codex-helper routing affinity",
1682                err.to_string(),
1683                "Inspect ~/.codex-helper/config.toml and choose an affinity policy appropriate for official relay features.",
1684            ),
1685        }
1686    }
1687}
1688
1689fn base_url_points_to_expected_local_port(base_url: Option<&str>, port: u16) -> bool {
1690    let Some(base_url) = base_url else {
1691        return false;
1692    };
1693    let port_marker = format!(":{port}");
1694    (base_url.contains("127.0.0.1") || base_url.contains("localhost") || base_url.contains("[::1]"))
1695        && base_url.contains(&port_marker)
1696}
1697
1698fn official_relay_affinity_policy_warning() -> Result<Option<String>> {
1699    let path = crate::config::config_file_path();
1700    if !path.exists() {
1701        return Ok(None);
1702    }
1703    let text = read_config_text(&path).with_context(|| format!("read {:?}", path))?;
1704    if text.trim().is_empty() {
1705        return Ok(None);
1706    }
1707    if path.extension().and_then(|ext| ext.to_str()) != Some("toml") {
1708        return Ok(None);
1709    }
1710
1711    let version = config_toml_schema_version_or_shape(&text);
1712    if !version.is_some_and(crate::config::is_supported_route_graph_config_version) {
1713        return Ok(None);
1714    }
1715
1716    let cfg = toml::from_str::<ProxyConfigV4>(&text)
1717        .with_context(|| format!("parse {:?} as route graph config", path))?;
1718    let Some(routing) = cfg.codex.routing.as_ref() else {
1719        return Ok(None);
1720    };
1721    if routing.affinity_policy != RoutingAffinityPolicyV5::PreferredGroup {
1722        return Ok(None);
1723    }
1724
1725    let authed_provider_count = cfg
1726        .codex
1727        .providers
1728        .values()
1729        .filter(|provider| provider.enabled && provider_v4_has_resolved_auth(provider))
1730        .count();
1731    if authed_provider_count <= 1 {
1732        return Ok(None);
1733    }
1734
1735    Ok(Some(format!(
1736        "codex-helper has {authed_provider_count} authenticated Codex providers and [codex.routing].affinity_policy is \"preferred-group\". Remote compaction v1 may include encrypted conversation state, so /responses and /responses/compact should stay on the same upstream account when a session has failed over."
1737    )))
1738}
1739
1740fn provider_v4_has_resolved_auth(provider: &crate::config::ProviderConfigV4) -> bool {
1741    upstream_auth_has_resolved_credential(&provider.auth)
1742        || upstream_auth_has_resolved_credential(&provider.inline_auth)
1743}
1744
1745fn collect_remote_control_startup_issues(
1746    report: &mut CodexStartupReadiness,
1747    status: &CodexRemoteControlStatus,
1748) {
1749    if status.remote_control_config_present {
1750        report.push(
1751            CodexStartupReadinessIssueKind::RemoteControlRemovedKeyPresent,
1752            CodexStartupReadinessSeverity::Warning,
1753            "Removed remote_control config key is present",
1754            format!(
1755                "{:?} contains [features].remote_control, which current Codex builds do not use for this enablement path.",
1756                status.config_path
1757            ),
1758            "Remove remote_control and keep [features].remote_connections = true instead.",
1759        );
1760    }
1761
1762    let remote_requested = status.remote_connections_enabled
1763        || status.remote_control_config_present
1764        || status.db_enabled == Some(true);
1765    if !remote_requested {
1766        return;
1767    }
1768
1769    if !remote_control_status_is_fully_enabled(status) {
1770        report.push(
1771            CodexStartupReadinessIssueKind::RemoteControlIncomplete,
1772            CodexStartupReadinessSeverity::Warning,
1773            "Codex App remote-control state is incomplete",
1774            format!(
1775                "remote_connections={}, db_exists={}, table_exists={}, db_enabled={}.",
1776                status.remote_connections_enabled,
1777                status.db_exists,
1778                status.db_table_exists,
1779                status
1780                    .db_enabled
1781                    .map(|value| value.to_string())
1782                    .unwrap_or_else(|| "<missing>".to_string())
1783            ),
1784            "Run `codex-helper switch remote-control enable`, then fully restart Codex App.",
1785        );
1786        return;
1787    }
1788
1789    match codex_remote_control_successful_enablement_log_seen() {
1790        Ok(true) => {}
1791        Ok(false) => report.push(
1792            CodexStartupReadinessIssueKind::RemoteControlLogUnconfirmed,
1793            CodexStartupReadinessSeverity::Warning,
1794            "Remote-control enablement is not confirmed in Codex logs",
1795            "The config and SQLite state look enabled, but no experimentalFeature/enablement/set success log was found.",
1796            "Fully restart Codex App, then run `codex-helper switch remote-control check-logs`.",
1797        ),
1798        Err(err) => report.push(
1799            CodexStartupReadinessIssueKind::DiagnosticError,
1800            CodexStartupReadinessSeverity::Warning,
1801            "Could not inspect Codex remote-control logs",
1802            err.to_string(),
1803            "Run `codex-helper switch remote-control check-logs` from a normal shell after restarting Codex App.",
1804        ),
1805    }
1806}
1807
1808fn remote_control_status_is_fully_enabled(status: &CodexRemoteControlStatus) -> bool {
1809    status.remote_connections_enabled
1810        && !status.remote_control_config_present
1811        && status.db_exists
1812        && status.db_table_exists
1813        && status.db_enabled == Some(true)
1814}
1815
1816pub fn codex_switch_status() -> Result<CodexSwitchStatus> {
1817    let cfg_path = codex_config_path();
1818    let state_path = codex_switch_state_path();
1819
1820    if !cfg_path.exists() {
1821        return Ok(CodexSwitchStatus {
1822            enabled: false,
1823            model_provider: None,
1824            provider_name: None,
1825            base_url: None,
1826            patch_mode: None,
1827            requires_openai_auth: None,
1828            supports_websockets: None,
1829            remote_compaction_v2_enabled: false,
1830            compaction_strategy: CodexCompactionStrategy::Auto,
1831            has_switch_state: state_path.exists(),
1832        });
1833    }
1834
1835    let text = read_config_text(&cfg_path)?;
1836    let remote_compaction_v2_enabled =
1837        codex_remote_compaction_v2_feature_enabled_from_toml(&text).unwrap_or(false);
1838    if text.trim().is_empty() {
1839        return Ok(CodexSwitchStatus {
1840            enabled: false,
1841            model_provider: None,
1842            provider_name: None,
1843            base_url: None,
1844            patch_mode: None,
1845            requires_openai_auth: None,
1846            supports_websockets: None,
1847            remote_compaction_v2_enabled,
1848            compaction_strategy: CodexCompactionStrategy::Auto,
1849            has_switch_state: state_path.exists(),
1850        });
1851    }
1852
1853    let value: Value = match text.parse() {
1854        Ok(v) => v,
1855        Err(_) => {
1856            return Ok(CodexSwitchStatus {
1857                enabled: false,
1858                model_provider: None,
1859                provider_name: None,
1860                base_url: None,
1861                patch_mode: None,
1862                requires_openai_auth: None,
1863                supports_websockets: None,
1864                remote_compaction_v2_enabled,
1865                compaction_strategy: CodexCompactionStrategy::Auto,
1866                has_switch_state: state_path.exists(),
1867            });
1868        }
1869    };
1870    let table = match value.as_table() {
1871        Some(t) => t,
1872        None => {
1873            return Ok(CodexSwitchStatus {
1874                enabled: false,
1875                model_provider: None,
1876                provider_name: None,
1877                base_url: None,
1878                patch_mode: None,
1879                requires_openai_auth: None,
1880                supports_websockets: None,
1881                remote_compaction_v2_enabled,
1882                compaction_strategy: CodexCompactionStrategy::Auto,
1883                has_switch_state: state_path.exists(),
1884            });
1885        }
1886    };
1887
1888    let model_provider = table
1889        .get("model_provider")
1890        .and_then(|v| v.as_str())
1891        .map(|s| s.to_string());
1892
1893    if model_provider.as_deref() != Some("codex_proxy") {
1894        return Ok(CodexSwitchStatus {
1895            enabled: false,
1896            model_provider,
1897            provider_name: None,
1898            base_url: None,
1899            patch_mode: None,
1900            requires_openai_auth: None,
1901            supports_websockets: None,
1902            remote_compaction_v2_enabled,
1903            compaction_strategy: CodexCompactionStrategy::Auto,
1904            has_switch_state: state_path.exists(),
1905        });
1906    }
1907
1908    let empty_map = toml::map::Map::new();
1909    let providers_table = table
1910        .get("model_providers")
1911        .and_then(|v| v.as_table())
1912        .unwrap_or(&empty_map);
1913    let empty_provider = toml::map::Map::new();
1914    let proxy_table = providers_table
1915        .get("codex_proxy")
1916        .and_then(|v| v.as_table())
1917        .unwrap_or(&empty_provider);
1918
1919    let base_url = proxy_table
1920        .get("base_url")
1921        .and_then(|v| v.as_str())
1922        .map(|s| s.to_string());
1923    let name = proxy_table
1924        .get("name")
1925        .and_then(|v| v.as_str())
1926        .unwrap_or_default();
1927    let provider_name = (!name.is_empty()).then(|| name.to_string());
1928    let requires_openai_auth = proxy_table
1929        .get("requires_openai_auth")
1930        .and_then(|v| v.as_bool());
1931    let supports_websockets = proxy_table
1932        .get("supports_websockets")
1933        .and_then(|v| v.as_bool());
1934
1935    let is_local = base_url
1936        .as_deref()
1937        .is_some_and(|u| u.contains("127.0.0.1") || u.contains("localhost"));
1938    let is_helper_name = name == "codex-helper";
1939    let enabled = is_local || is_helper_name;
1940
1941    let stored_state = read_codex_switch_state()?;
1942    let stored_patch_mode = stored_state.as_ref().and_then(|state| state.patch_mode);
1943    let inferred_patch_mode = if requires_openai_auth == Some(true) {
1944        CodexPatchMode::ChatGptBridge
1945    } else if name == "OpenAI" {
1946        if current_auth_json_is_empty_chatgpt_facade() {
1947            CodexPatchMode::OfficialImagegenBridge
1948        } else {
1949            CodexPatchMode::OfficialRelayBridge
1950        }
1951    } else {
1952        CodexPatchMode::Default
1953    };
1954    let inferred_compaction_strategy = if name == "OpenAI" {
1955        if remote_compaction_v2_enabled {
1956            CodexCompactionStrategy::RemoteV2
1957        } else {
1958            CodexCompactionStrategy::RemoteV1
1959        }
1960    } else {
1961        CodexCompactionStrategy::Local
1962    };
1963    let compaction_strategy = stored_state
1964        .as_ref()
1965        .map(|state| state.compaction)
1966        .unwrap_or(inferred_compaction_strategy);
1967
1968    Ok(CodexSwitchStatus {
1969        enabled,
1970        model_provider,
1971        provider_name,
1972        base_url,
1973        patch_mode: enabled.then_some(stored_patch_mode.unwrap_or(inferred_patch_mode)),
1974        requires_openai_auth,
1975        supports_websockets,
1976        remote_compaction_v2_enabled,
1977        compaction_strategy,
1978        has_switch_state: state_path.exists(),
1979    })
1980}
1981
1982/// Switch Codex to use the local codex-helper model provider.
1983pub fn switch_on(port: u16) -> Result<()> {
1984    switch_on_with_mode(port, CodexPatchMode::Default)
1985}
1986
1987/// Switch Codex to use the local codex-helper model provider using the preset configured in
1988/// `~/.codex-helper/config.toml`.
1989pub fn switch_on_with_configured_preset(port: u16) -> Result<()> {
1990    let client_patch = crate::config::codex_client_patch_config_from_config_file()
1991        .context("read codex.client_patch from codex-helper config before switch on")?;
1992    switch_on_with_base_url_and_hosted_image_generation(
1993        &format!("http://127.0.0.1:{port}"),
1994        client_patch.preset,
1995        client_patch.options,
1996        client_patch.hosted_image_generation,
1997    )
1998}
1999
2000/// Switch Codex to use the local codex-helper model provider with an explicit client preset.
2001pub fn switch_on_with_mode(port: u16, mode: CodexPatchMode) -> Result<()> {
2002    switch_on_with_options(port, mode, CodexSwitchOptions::default())
2003}
2004
2005/// Switch Codex to use the local codex-helper model provider with an explicit client preset and
2006/// transport options.
2007pub fn switch_on_with_options(
2008    port: u16,
2009    mode: CodexPatchMode,
2010    options: CodexSwitchOptions,
2011) -> Result<()> {
2012    switch_on_with_base_url(&format!("http://127.0.0.1:{port}"), mode, options)
2013}
2014
2015/// Switch Codex to use a codex-helper-compatible model provider at an explicit base URL.
2016pub fn switch_on_with_base_url(
2017    base_url: &str,
2018    mode: CodexPatchMode,
2019    options: CodexSwitchOptions,
2020) -> Result<()> {
2021    switch_on_with_base_url_and_hosted_image_generation(
2022        base_url,
2023        mode,
2024        options,
2025        CodexHostedImageGenerationMode::Auto,
2026    )
2027}
2028
2029fn switch_on_with_base_url_and_hosted_image_generation(
2030    base_url: &str,
2031    mode: CodexPatchMode,
2032    options: CodexSwitchOptions,
2033    hosted_image_generation: CodexHostedImageGenerationMode,
2034) -> Result<()> {
2035    let base_url = crate::control_plane_client::normalize_base_url(base_url)
2036        .ok_or_else(|| anyhow!("Codex proxy base URL must start with http:// or https://"))?;
2037    let plan = CodexPatchPlan::for_switch_on(mode, options)?;
2038    if plan.requires_bridge_runtime_ready() {
2039        ensure_bridge_runtime_ready(plan.mode())?;
2040    }
2041
2042    let cfg_path = codex_config_path();
2043    let state_path = codex_switch_state_path();
2044    let text = read_config_text(&cfg_path)?;
2045    if !state_path.exists() && codex_text_points_to_local_helper(&text)? {
2046        return Err(anyhow!(
2047            "Codex already points to the local codex-helper proxy, but no switch state was found at {:?}; refusing to treat the local proxy as the original provider. Please inspect ~/.codex/config.toml manually or run `codex-helper switch off` only if a switch state exists.",
2048            state_path
2049        ));
2050    }
2051    let mut state = if state_path.exists() {
2052        read_codex_switch_state()?.ok_or_else(|| {
2053            anyhow!(
2054                "missing Codex switch state at {:?}",
2055                codex_switch_state_path()
2056            )
2057        })?
2058    } else {
2059        CodexSwitchState::from_codex_config_text(&text, !cfg_path.exists())?
2060    };
2061    state.patch_mode = Some(plan.mode());
2062    state.responses_websocket = plan.options().responses_websocket;
2063    state.compaction = plan.options().compaction;
2064
2065    let auth_edit = auth_edit_for_switch_on_plan(plan, &mut state)?;
2066    let new_text = switch_on_codex_toml_with_base_url_and_plan(
2067        &text,
2068        &base_url,
2069        plan,
2070        hosted_image_generation,
2071    )?;
2072    apply_switch_on_effects(plan, &cfg_path, &new_text, &state, auth_edit)?;
2073    Ok(())
2074}
2075
2076/// Undo the local Codex proxy patch while preserving config edits made during the run.
2077pub fn switch_off() -> Result<()> {
2078    let cfg_path = codex_config_path();
2079    let state_path = codex_switch_state_path();
2080    if state_path.exists() {
2081        let mut state = read_codex_switch_state()?.ok_or_else(|| {
2082            anyhow!(
2083                "missing Codex switch state at {:?}",
2084                codex_switch_state_path()
2085            )
2086        })?;
2087        let auth_edit = auth_restore_edit_from_state(&mut state)?;
2088        if !cfg_path.exists() {
2089            apply_codex_auth_edit(auth_edit)?;
2090            fs::remove_file(&state_path)
2091                .with_context(|| format!("remove stale switch state {:?}", state_path))?;
2092            return Ok(());
2093        }
2094        let current_text = read_config_text(&cfg_path)?;
2095        match switch_off_codex_toml(&current_text, &state)? {
2096            CodexSwitchOffEdit::RemoveFile => {
2097                if cfg_path.exists() {
2098                    fs::remove_file(&cfg_path)
2099                        .with_context(|| format!("remove {:?} (restore absent)", cfg_path))?;
2100                }
2101            }
2102            CodexSwitchOffEdit::Write(text) => {
2103                atomic_write(&cfg_path, &text)
2104                    .with_context(|| format!("patch {:?} to disable local proxy", cfg_path))?;
2105            }
2106        }
2107        apply_codex_auth_edit(auth_edit)?;
2108        fs::remove_file(&state_path)
2109            .with_context(|| format!("remove stale switch state {:?}", state_path))?;
2110    }
2111    Ok(())
2112}
2113
2114#[derive(Debug, Clone)]
2115pub struct CodexRemoteControlStatus {
2116    pub config_path: PathBuf,
2117    pub remote_connections_enabled: bool,
2118    pub remote_control_config_present: bool,
2119    pub db_path: PathBuf,
2120    pub db_exists: bool,
2121    pub db_table_exists: bool,
2122    pub db_enabled: Option<bool>,
2123    pub db_updated_at: Option<i64>,
2124}
2125
2126#[derive(Debug, Clone)]
2127pub struct CodexRemoteControlEnablement {
2128    pub status: CodexRemoteControlStatus,
2129    pub backup_path: PathBuf,
2130}
2131
2132pub fn codex_remote_control_enable() -> Result<CodexRemoteControlEnablement> {
2133    let cfg_path = codex_config_path();
2134    let config_text = read_config_text(&cfg_path)?;
2135    let updated_config = ensure_codex_remote_connections_feature_in_toml(&config_text)?;
2136    let db_path = codex_app_db_path();
2137
2138    validate_codex_remote_control_feature_enablement_schema(&db_path)?;
2139    let backup_path = backup_codex_app_db(&db_path)?;
2140
2141    if updated_config != config_text {
2142        atomic_write(&cfg_path, &updated_config)
2143            .with_context(|| format!("patch {:?} for Codex remote connections", cfg_path))?;
2144    }
2145
2146    upsert_codex_remote_control_feature_enablement(&db_path)?;
2147    let status = codex_remote_control_status()?;
2148
2149    Ok(CodexRemoteControlEnablement {
2150        status,
2151        backup_path,
2152    })
2153}
2154
2155pub fn codex_remote_control_status() -> Result<CodexRemoteControlStatus> {
2156    let config_path = codex_config_path();
2157    let config_text = read_config_text(&config_path)?;
2158    let remote_connections_enabled =
2159        codex_remote_connections_feature_enabled_from_toml(&config_text)?;
2160    let remote_control_config_present = codex_remote_control_feature_present_in_toml(&config_text)?;
2161
2162    let db_path = codex_app_db_path();
2163    let db_exists = db_path.exists();
2164    let db_status = if db_exists {
2165        read_codex_remote_control_db_status(&db_path)?
2166    } else {
2167        CodexRemoteControlDbStatus {
2168            table_exists: false,
2169            enabled: None,
2170            updated_at: None,
2171        }
2172    };
2173
2174    Ok(CodexRemoteControlStatus {
2175        config_path,
2176        remote_connections_enabled,
2177        remote_control_config_present,
2178        db_path,
2179        db_exists,
2180        db_table_exists: db_status.table_exists,
2181        db_enabled: db_status.enabled,
2182        db_updated_at: db_status.updated_at,
2183    })
2184}
2185
2186fn backup_codex_app_db(db_path: &Path) -> Result<PathBuf> {
2187    if !db_path.exists() {
2188        return Err(anyhow!(
2189            "Codex App SQLite database not found at {:?}; open Codex App once so it creates the local database, then retry",
2190            db_path
2191        ));
2192    }
2193
2194    let timestamp = timestamp_for_backup_filename();
2195    let file_name = db_path
2196        .file_name()
2197        .and_then(|name| name.to_str())
2198        .unwrap_or("codex-dev.db");
2199    let backup_path = db_path.with_file_name(format!("{file_name}.{timestamp}.bak"));
2200    fs::copy(db_path, &backup_path)
2201        .with_context(|| format!("backup {:?} -> {:?}", db_path, backup_path))?;
2202    Ok(backup_path)
2203}
2204
2205fn validate_codex_remote_control_feature_enablement_schema(db_path: &Path) -> Result<()> {
2206    if !db_path.exists() {
2207        return Err(anyhow!(
2208            "Codex App SQLite database not found at {:?}; open Codex App once so it creates the local database, then retry",
2209            db_path
2210        ));
2211    }
2212
2213    let conn = rusqlite::Connection::open(db_path)
2214        .with_context(|| format!("open Codex App SQLite database {:?}", db_path))?;
2215    let columns = local_app_server_feature_enablement_columns(&conn)?;
2216    if columns.is_empty() {
2217        return Err(anyhow!(
2218            "SQLite table local_app_server_feature_enablement not found in {:?}; this Codex App build may use a different desktop state schema",
2219            db_path
2220        ));
2221    }
2222    ensure_required_enablement_columns(&columns)
2223}
2224
2225fn timestamp_for_backup_filename() -> String {
2226    use std::time::{SystemTime, UNIX_EPOCH};
2227
2228    let millis = SystemTime::now()
2229        .duration_since(UNIX_EPOCH)
2230        .map(|duration| duration.as_millis())
2231        .unwrap_or(0);
2232    millis.to_string()
2233}
2234
2235fn current_unix_millis_i64() -> i64 {
2236    use std::time::{SystemTime, UNIX_EPOCH};
2237
2238    SystemTime::now()
2239        .duration_since(UNIX_EPOCH)
2240        .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
2241        .unwrap_or(0)
2242}
2243
2244fn upsert_codex_remote_control_feature_enablement(db_path: &Path) -> Result<()> {
2245    let conn = rusqlite::Connection::open(db_path)
2246        .with_context(|| format!("open Codex App SQLite database {:?}", db_path))?;
2247    let columns = local_app_server_feature_enablement_columns(&conn)?;
2248    if columns.is_empty() {
2249        return Err(anyhow!(
2250            "SQLite table local_app_server_feature_enablement not found in {:?}; this Codex App build may use a different desktop state schema",
2251            db_path
2252        ));
2253    }
2254    ensure_required_enablement_columns(&columns)?;
2255
2256    let updated_at = current_unix_millis_i64();
2257    let column_names = columns
2258        .iter()
2259        .map(|column| column.name.as_str())
2260        .collect::<Vec<_>>();
2261    let has_created_at = column_names.contains(&"created_at");
2262
2263    let updated_rows = conn
2264        .execute(
2265            "UPDATE local_app_server_feature_enablement \
2266             SET enabled = ?1, updated_at = ?2 \
2267             WHERE feature_name = ?3",
2268            rusqlite::params![1_i64, updated_at, "remote_control"],
2269        )
2270        .with_context(|| {
2271            format!(
2272                "update remote_control in local_app_server_feature_enablement in {:?}",
2273                db_path
2274            )
2275        })?;
2276    if updated_rows > 0 {
2277        return Ok(());
2278    }
2279
2280    let mut insert_columns = vec!["feature_name", "enabled", "updated_at"];
2281    let mut insert_values = vec!["?1", "?2", "?3"];
2282    if has_created_at {
2283        insert_columns.push("created_at");
2284        insert_values.push("?3");
2285    }
2286
2287    let sql = format!(
2288        "INSERT INTO local_app_server_feature_enablement ({}) VALUES ({})",
2289        insert_columns.join(", "),
2290        insert_values.join(", ")
2291    );
2292    conn.execute(
2293        sql.as_str(),
2294        rusqlite::params!["remote_control", 1_i64, updated_at],
2295    )
2296    .with_context(|| {
2297        format!(
2298            "upsert remote_control into local_app_server_feature_enablement in {:?}",
2299            db_path
2300        )
2301    })?;
2302
2303    Ok(())
2304}
2305
2306#[derive(Debug)]
2307struct SqliteColumnInfo {
2308    name: String,
2309    not_null: bool,
2310    pk: i32,
2311}
2312
2313fn local_app_server_feature_enablement_columns(
2314    conn: &rusqlite::Connection,
2315) -> Result<Vec<SqliteColumnInfo>> {
2316    let mut stmt = conn
2317        .prepare("PRAGMA table_info(local_app_server_feature_enablement)")
2318        .context("prepare table_info(local_app_server_feature_enablement)")?;
2319    let rows = stmt
2320        .query_map([], |row| {
2321            Ok(SqliteColumnInfo {
2322                name: row.get(1)?,
2323                not_null: row.get::<_, i64>(3)? != 0,
2324                pk: row.get(5)?,
2325            })
2326        })
2327        .context("query table_info(local_app_server_feature_enablement)")?;
2328
2329    let mut columns = Vec::new();
2330    for row in rows {
2331        columns.push(row?);
2332    }
2333    Ok(columns)
2334}
2335
2336fn ensure_required_enablement_columns(columns: &[SqliteColumnInfo]) -> Result<()> {
2337    for required in ["feature_name", "enabled", "updated_at"] {
2338        if !columns.iter().any(|column| column.name == required) {
2339            return Err(anyhow!(
2340                "SQLite table local_app_server_feature_enablement is missing required column `{required}`"
2341            ));
2342        }
2343    }
2344
2345    let optional_supported = ["id", "created_at", "feature_name", "enabled", "updated_at"];
2346    let unsupported_required = columns
2347        .iter()
2348        .filter(|column| column.not_null && column.pk == 0)
2349        .filter(|column| {
2350            !optional_supported
2351                .iter()
2352                .any(|supported| *supported == column.name)
2353        })
2354        .map(|column| column.name.as_str())
2355        .collect::<Vec<_>>();
2356    if !unsupported_required.is_empty() {
2357        return Err(anyhow!(
2358            "SQLite table local_app_server_feature_enablement has unsupported NOT NULL columns without known values: {}",
2359            unsupported_required.join(", ")
2360        ));
2361    }
2362
2363    Ok(())
2364}
2365
2366#[derive(Debug)]
2367struct CodexRemoteControlDbStatus {
2368    table_exists: bool,
2369    enabled: Option<bool>,
2370    updated_at: Option<i64>,
2371}
2372
2373fn read_codex_remote_control_db_status(db_path: &Path) -> Result<CodexRemoteControlDbStatus> {
2374    let conn = rusqlite::Connection::open(db_path)
2375        .with_context(|| format!("open Codex App SQLite database {:?}", db_path))?;
2376    let columns = local_app_server_feature_enablement_columns(&conn)?;
2377    if columns.is_empty() {
2378        return Ok(CodexRemoteControlDbStatus {
2379            table_exists: false,
2380            enabled: None,
2381            updated_at: None,
2382        });
2383    }
2384
2385    ensure_required_enablement_columns(&columns)?;
2386    let mut stmt = conn
2387        .prepare(
2388            "SELECT enabled, updated_at \
2389             FROM local_app_server_feature_enablement \
2390             WHERE feature_name = ?1 \
2391             ORDER BY updated_at DESC \
2392             LIMIT 1",
2393        )
2394        .context("prepare local_app_server_feature_enablement status query")?;
2395    let mut rows = stmt
2396        .query(rusqlite::params!["remote_control"])
2397        .context("query local_app_server_feature_enablement status")?;
2398
2399    if let Some(row) = rows.next()? {
2400        Ok(CodexRemoteControlDbStatus {
2401            table_exists: true,
2402            enabled: Some(row.get::<_, i64>(0)? != 0),
2403            updated_at: row.get(1)?,
2404        })
2405    } else {
2406        Ok(CodexRemoteControlDbStatus {
2407            table_exists: true,
2408            enabled: None,
2409            updated_at: None,
2410        })
2411    }
2412}
2413
2414pub fn codex_remote_control_successful_enablement_log_seen() -> Result<bool> {
2415    let base_dir = codex_config_path()
2416        .parent()
2417        .map(Path::to_path_buf)
2418        .unwrap_or_else(|| PathBuf::from("."));
2419    for candidate in [base_dir.join("log"), base_dir.join("logs")] {
2420        if codex_remote_control_successful_enablement_log_seen_in_dir(candidate.as_path())? {
2421            return Ok(true);
2422        }
2423    }
2424    Ok(false)
2425}
2426
2427fn codex_remote_control_successful_enablement_log_seen_in_dir(log_dir: &Path) -> Result<bool> {
2428    if !log_dir.exists() {
2429        return Ok(false);
2430    }
2431
2432    let mut files = Vec::new();
2433    collect_regular_files(log_dir, &mut files)?;
2434    files.sort_by_key(|path| {
2435        fs::metadata(path)
2436            .and_then(|metadata| metadata.modified())
2437            .ok()
2438    });
2439
2440    for path in files.into_iter().rev().take(20) {
2441        if file_contains_remote_control_success(path.as_path())? {
2442            return Ok(true);
2443        }
2444    }
2445    Ok(false)
2446}
2447
2448fn collect_regular_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
2449    for entry in fs::read_dir(dir).with_context(|| format!("read_dir {:?}", dir))? {
2450        let entry = entry.with_context(|| format!("read entry in {:?}", dir))?;
2451        let path = entry.path();
2452        let file_type = entry
2453            .file_type()
2454            .with_context(|| format!("read file type for {:?}", path))?;
2455        if file_type.is_dir() {
2456            collect_regular_files(&path, out)?;
2457        } else if file_type.is_file() {
2458            out.push(path);
2459        }
2460    }
2461    Ok(())
2462}
2463
2464fn file_contains_remote_control_success(path: &Path) -> Result<bool> {
2465    const MAX_BYTES: u64 = 2 * 1024 * 1024;
2466    let mut file = fs::File::open(path).with_context(|| format!("open log {:?}", path))?;
2467    let len = file.metadata()?.len();
2468    if len > MAX_BYTES {
2469        file.seek(SeekFrom::End(-(MAX_BYTES as i64)))
2470            .with_context(|| format!("seek log {:?}", path))?;
2471    }
2472    let mut bytes = Vec::new();
2473    file.read_to_end(&mut bytes)
2474        .with_context(|| format!("read log {:?}", path))?;
2475    let text = String::from_utf8_lossy(&bytes);
2476    Ok(text.contains("experimentalFeature/enablement/set")
2477        && (text.contains("errorCode=null")
2478            || text.contains("\"errorCode\":null")
2479            || text.contains("'errorCode':null")))
2480}
2481
2482#[derive(Debug, Clone)]
2483pub struct ClaudeSwitchStatus {
2484    /// Whether Claude Code currently appears to be configured to use the local codex-helper proxy.
2485    pub enabled: bool,
2486    /// Current `env.ANTHROPIC_BASE_URL` value (if any).
2487    pub base_url: Option<String>,
2488    /// Whether a backup file exists for safe restore.
2489    pub has_backup: bool,
2490    /// The resolved settings file path (settings.json or legacy claude.json).
2491    pub settings_path: PathBuf,
2492}
2493
2494pub fn claude_switch_status() -> Result<ClaudeSwitchStatus> {
2495    let settings_path = claude_settings_path();
2496    let backup_path = claude_settings_backup_path(&settings_path);
2497
2498    if !settings_path.exists() {
2499        return Ok(ClaudeSwitchStatus {
2500            enabled: false,
2501            base_url: None,
2502            has_backup: backup_path.exists(),
2503            settings_path,
2504        });
2505    }
2506
2507    let text = read_settings_text(&settings_path)?;
2508    if text.trim().is_empty() {
2509        return Ok(ClaudeSwitchStatus {
2510            enabled: false,
2511            base_url: None,
2512            has_backup: backup_path.exists(),
2513            settings_path,
2514        });
2515    }
2516
2517    let value: serde_json::Value = match serde_json::from_str(&text) {
2518        Ok(v) => v,
2519        Err(_) => {
2520            return Ok(ClaudeSwitchStatus {
2521                enabled: false,
2522                base_url: None,
2523                has_backup: backup_path.exists(),
2524                settings_path,
2525            });
2526        }
2527    };
2528
2529    let env_obj = value
2530        .as_object()
2531        .and_then(|o| o.get("env"))
2532        .and_then(|v| v.as_object());
2533
2534    let base_url = env_obj
2535        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
2536        .and_then(|v| v.as_str())
2537        .map(|s| s.to_string());
2538
2539    let enabled = base_url
2540        .as_deref()
2541        .is_some_and(|u| u.contains("127.0.0.1") || u.contains("localhost"));
2542
2543    Ok(ClaudeSwitchStatus {
2544        enabled,
2545        base_url,
2546        has_backup: backup_path.exists(),
2547        settings_path,
2548    })
2549}
2550
2551/// Warn before replacing an existing local Codex proxy patch.
2552pub fn guard_codex_config_before_switch_on_interactive() -> Result<()> {
2553    use std::io::{self, Write};
2554
2555    let cfg_path = codex_config_path();
2556    let state_path = codex_switch_state_path();
2557
2558    if !cfg_path.exists() {
2559        return Ok(());
2560    }
2561
2562    let text = read_config_text(&cfg_path)?;
2563    if text.trim().is_empty() {
2564        return Ok(());
2565    }
2566
2567    let value: Value = match text.parse() {
2568        Ok(value) => value,
2569        Err(_) => return Ok(()),
2570    };
2571    let table = match value.as_table() {
2572        Some(table) => table,
2573        None => return Ok(()),
2574    };
2575
2576    let current_provider = table
2577        .get("model_provider")
2578        .and_then(|value| value.as_str())
2579        .unwrap_or_default();
2580    if current_provider != "codex_proxy" {
2581        return Ok(());
2582    }
2583
2584    let empty_map = toml::map::Map::new();
2585    let providers_table = table
2586        .get("model_providers")
2587        .and_then(|value| value.as_table())
2588        .unwrap_or(&empty_map);
2589    let empty_provider = toml::map::Map::new();
2590    let proxy_table = providers_table
2591        .get("codex_proxy")
2592        .and_then(|value| value.as_table())
2593        .unwrap_or(&empty_provider);
2594
2595    let base_url = proxy_table
2596        .get("base_url")
2597        .and_then(|value| value.as_str())
2598        .unwrap_or_default();
2599    let name = proxy_table
2600        .get("name")
2601        .and_then(|value| value.as_str())
2602        .unwrap_or_default();
2603
2604    let is_local = base_url.contains("127.0.0.1") || base_url.contains("localhost");
2605    let is_helper_name = name == "codex-helper";
2606    if !is_local && !is_helper_name {
2607        return Ok(());
2608    }
2609
2610    if !state_path.exists() {
2611        eprintln!(
2612            "Warning: Codex currently points to the local proxy ({base_url}), but no codex-helper switch state {:?} was found; please inspect ~/.codex/config.toml manually if this is unexpected.",
2613            state_path
2614        );
2615        return Ok(());
2616    }
2617
2618    let is_tty = atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout);
2619    if !is_tty {
2620        eprintln!(
2621            "Notice: Codex currently points to local codex-helper ({base_url}) and switch state {:?} exists; run `codex-helper switch off` to disable the local proxy patch while preserving other config edits.",
2622            state_path
2623        );
2624        return Ok(());
2625    }
2626
2627    eprintln!(
2628        "Codex currently points to local codex-helper ({base_url}), and switch state {:?} exists.\nThis usually means the previous run did not switch off cleanly.\nDisable the local proxy patch now while preserving other config edits? [Y/n] ",
2629        state_path
2630    );
2631    eprint!("> ");
2632    io::stdout().flush().ok();
2633
2634    let mut input = String::new();
2635    if let Err(err) = io::stdin().read_line(&mut input) {
2636        eprintln!("Failed to read input: {err}");
2637        return Ok(());
2638    }
2639    let answer = input.trim();
2640    let yes =
2641        answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes");
2642
2643    if yes {
2644        if let Err(err) = switch_off() {
2645            eprintln!("Failed to disable local Codex proxy patch: {err}");
2646        } else {
2647            eprintln!("Disabled local Codex proxy patch.");
2648        }
2649    } else {
2650        eprintln!("Keeping current Codex config unchanged.");
2651    }
2652
2653    Ok(())
2654}
2655
2656fn read_settings_text(path: &Path) -> Result<String> {
2657    if !path.exists() {
2658        return Ok(String::new());
2659    }
2660    let mut file = fs::File::open(path).with_context(|| format!("open {:?}", path))?;
2661    let mut buf = String::new();
2662    file.read_to_string(&mut buf)
2663        .with_context(|| format!("read {:?}", path))?;
2664    Ok(buf)
2665}
2666
2667pub fn claude_switch_on(port: u16) -> Result<()> {
2668    claude_switch_on_base_url(&format!("http://127.0.0.1:{port}"))
2669}
2670
2671pub fn claude_switch_on_base_url(base_url: &str) -> Result<()> {
2672    let base_url = crate::control_plane_client::normalize_base_url(base_url)
2673        .ok_or_else(|| anyhow!("Claude proxy base URL must start with http:// or https://"))?;
2674    let settings_path = claude_settings_path();
2675    let backup_path = claude_settings_backup_path(&settings_path);
2676
2677    if settings_path.exists() && !backup_path.exists() {
2678        fs::copy(&settings_path, &backup_path).with_context(|| {
2679            format!(
2680                "backup Claude settings {:?} -> {:?}",
2681                settings_path, backup_path
2682            )
2683        })?;
2684    } else if !settings_path.exists() && !backup_path.exists() {
2685        // If Claude Code has no settings yet, create a sentinel backup so we can restore
2686        // to the "absent" state on claude_switch_off.
2687        if let Some(parent) = backup_path.parent() {
2688            fs::create_dir_all(parent).with_context(|| format!("create_dir_all {:?}", parent))?;
2689        }
2690        fs::write(&backup_path, CLAUDE_ABSENT_BACKUP_SENTINEL)
2691            .with_context(|| format!("write {:?}", backup_path))?;
2692    }
2693
2694    let text = read_settings_text(&settings_path)?;
2695    let mut value: serde_json::Value = if text.trim().is_empty() {
2696        serde_json::json!({})
2697    } else {
2698        serde_json::from_str(&text).with_context(|| format!("parse {:?} as JSON", settings_path))?
2699    };
2700
2701    let obj = value
2702        .as_object_mut()
2703        .ok_or_else(|| anyhow!("Claude settings root must be an object"))?;
2704
2705    let env_val = obj
2706        .entry("env".to_string())
2707        .or_insert_with(|| serde_json::json!({}));
2708    let env_obj = env_val
2709        .as_object_mut()
2710        .ok_or_else(|| anyhow!("Claude settings env must be an object"))?;
2711
2712    env_obj.insert(
2713        "ANTHROPIC_BASE_URL".to_string(),
2714        serde_json::Value::String(base_url),
2715    );
2716
2717    let new_text = serde_json::to_string_pretty(&value)?;
2718    write_text_file(&settings_path, &new_text)
2719        .with_context(|| format!("write {:?}", settings_path))?;
2720
2721    eprintln!(
2722        "[EXPERIMENTAL] Updated {:?} to use Claude proxy via codex-helper",
2723        settings_path
2724    );
2725    Ok(())
2726}
2727
2728pub fn claude_switch_off() -> Result<()> {
2729    let settings_path = claude_settings_path();
2730    let backup_path = claude_settings_backup_path(&settings_path);
2731    if backup_path.exists() {
2732        let text = read_settings_text(&backup_path)?;
2733        if text.trim() == CLAUDE_ABSENT_BACKUP_SENTINEL {
2734            if settings_path.exists() {
2735                fs::remove_file(&settings_path)
2736                    .with_context(|| format!("remove {:?} (restore absent)", settings_path))?;
2737            }
2738        } else {
2739            atomic_write(&settings_path, &text)
2740                .with_context(|| format!("restore {:?} -> {:?}", backup_path, settings_path))?;
2741            eprintln!(
2742                "[EXPERIMENTAL] Restored Claude settings from backup {:?}",
2743                backup_path
2744            );
2745        }
2746        fs::remove_file(&backup_path)
2747            .with_context(|| format!("remove stale backup {:?}", backup_path))?;
2748    }
2749    Ok(())
2750}
2751
2752#[cfg(test)]
2753#[allow(clippy::items_after_test_module)]
2754mod tests {
2755    use super::*;
2756    use base64::Engine as _;
2757    use serde_json::json;
2758    use std::path::Path;
2759    use std::sync::{Mutex, OnceLock};
2760
2761    struct ScopedEnv {
2762        saved: Vec<(String, Option<String>)>,
2763    }
2764
2765    impl ScopedEnv {
2766        fn new() -> Self {
2767            Self { saved: Vec::new() }
2768        }
2769
2770        unsafe fn set_path(&mut self, key: &str, value: &Path) {
2771            self.saved.push((key.to_string(), std::env::var(key).ok()));
2772            unsafe { std::env::set_var(key, value) };
2773        }
2774
2775        unsafe fn set_str(&mut self, key: &str, value: &str) {
2776            self.saved.push((key.to_string(), std::env::var(key).ok()));
2777            unsafe { std::env::set_var(key, value) };
2778        }
2779    }
2780
2781    impl Drop for ScopedEnv {
2782        fn drop(&mut self) {
2783            for (key, old) in self.saved.drain(..).rev() {
2784                unsafe {
2785                    match old {
2786                        Some(value) => std::env::set_var(&key, value),
2787                        None => std::env::remove_var(&key),
2788                    }
2789                }
2790            }
2791        }
2792    }
2793
2794    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2795        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2796        match LOCK.get_or_init(|| Mutex::new(())).lock() {
2797            Ok(guard) => guard,
2798            Err(err) => err.into_inner(),
2799        }
2800    }
2801
2802    struct TestEnv {
2803        _lock: std::sync::MutexGuard<'static, ()>,
2804        _env: ScopedEnv,
2805        codex_home: PathBuf,
2806        claude_home: PathBuf,
2807    }
2808
2809    fn setup_temp_env() -> TestEnv {
2810        let lock = env_lock();
2811        let root =
2812            std::env::temp_dir().join(format!("codex-helper-switch-test-{}", uuid::Uuid::new_v4()));
2813        std::fs::create_dir_all(&root).expect("create temp root");
2814
2815        let codex_home = root.join(".codex");
2816        let claude_home = root.join(".claude");
2817        let proxy_home = root.join(".codex-helper");
2818        std::fs::create_dir_all(&codex_home).expect("create temp codex home");
2819        std::fs::create_dir_all(&claude_home).expect("create temp claude home");
2820        std::fs::create_dir_all(&proxy_home).expect("create temp proxy home");
2821
2822        let mut scoped = ScopedEnv::new();
2823        unsafe {
2824            scoped.set_path("CODEX_HOME", &codex_home);
2825            scoped.set_path("CLAUDE_HOME", &claude_home);
2826            scoped.set_path("CODEX_HELPER_HOME", &proxy_home);
2827            scoped.set_path("HOME", &root);
2828            scoped.set_path("USERPROFILE", &root);
2829            scoped.set_str("CODEX_HELPER_IMAGEGEN_TEST_KEY", "sk-relay-test");
2830            scoped.set_str("CODEX_HELPER_MISSING_IMAGEGEN_KEY", "");
2831        }
2832
2833        TestEnv {
2834            _lock: lock,
2835            _env: scoped,
2836            codex_home,
2837            claude_home,
2838        }
2839    }
2840
2841    fn write_file(path: &Path, content: &str) {
2842        if let Some(parent) = path.parent() {
2843            std::fs::create_dir_all(parent).expect("create parent directories");
2844        }
2845        std::fs::write(path, content).expect("write test file");
2846    }
2847
2848    fn read_file(path: &Path) -> String {
2849        std::fs::read_to_string(path).expect("read test file")
2850    }
2851
2852    fn write_helper_codex_config(env: &TestEnv, content: &str) {
2853        let proxy_home = env.codex_home.parent().unwrap().join(".codex-helper");
2854        write_file(&proxy_home.join("config.toml"), content.trim_start());
2855    }
2856
2857    fn write_helper_codex_config_with_env_auth(env: &TestEnv) {
2858        write_helper_codex_config(
2859            env,
2860            r#"
2861version = 5
2862
2863[codex.providers.relay]
2864base_url = "https://relay.example/v1"
2865auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
2866
2867[codex.routing]
2868entry = "main"
2869
2870[codex.routing.routes.main]
2871strategy = "ordered-failover"
2872children = ["relay"]
2873"#,
2874        );
2875    }
2876
2877    fn fake_chatgpt_jwt(email: &str, account_id: &str, plan_type: &str) -> String {
2878        let header = json!({
2879            "alg": "none",
2880            "typ": "JWT",
2881        });
2882        let payload = json!({
2883            "email": email,
2884            "https://api.openai.com/auth": {
2885                "chatgpt_account_id": account_id,
2886                "chatgpt_plan_type": plan_type,
2887            },
2888        });
2889        let encode = |value: serde_json::Value| {
2890            let bytes = serde_json::to_vec(&value).expect("serialize JWT part");
2891            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
2892        };
2893        format!("{}.{}.sig", encode(header), encode(payload))
2894    }
2895
2896    fn chatgpt_auth_json(email: &str, account_id: &str, plan_type: &str) -> String {
2897        let id_token = fake_chatgpt_jwt(email, account_id, plan_type);
2898        serde_json::to_string_pretty(&json!({
2899            "auth_mode": "chatgpt",
2900            "OPENAI_API_KEY": "sk-platform-onboarding",
2901            "tokens": {
2902                "id_token": id_token,
2903                "access_token": "chatgpt-access-token",
2904                "refresh_token": "chatgpt-refresh-token",
2905                "account_id": account_id,
2906            },
2907            "last_refresh": "2026-05-17T00:00:00Z",
2908        }))
2909        .expect("serialize chatgpt auth fixture")
2910    }
2911
2912    fn chatgpt_auth_json_without_plan(email: &str, account_id: &str) -> String {
2913        let header = json!({
2914            "alg": "none",
2915            "typ": "JWT",
2916        });
2917        let payload = json!({
2918            "email": email,
2919            "https://api.openai.com/auth": {
2920                "chatgpt_account_id": account_id,
2921            },
2922        });
2923        let encode = |value: serde_json::Value| {
2924            let bytes = serde_json::to_vec(&value).expect("serialize JWT part");
2925            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
2926        };
2927        let id_token = format!("{}.{}.sig", encode(header), encode(payload));
2928        serde_json::to_string_pretty(&json!({
2929            "auth_mode": "chatgpt",
2930            "OPENAI_API_KEY": null,
2931            "tokens": {
2932                "id_token": id_token,
2933                "access_token": "chatgpt-access-token",
2934                "refresh_token": "chatgpt-refresh-token",
2935                "account_id": account_id,
2936            },
2937            "last_refresh": "2026-05-17T00:00:00Z",
2938        }))
2939        .expect("serialize chatgpt auth fixture")
2940    }
2941
2942    #[test]
2943    fn codex_switch_on_preserves_unrelated_toml_comments_and_fields() {
2944        let env = setup_temp_env();
2945        let cfg_path = env.codex_home.join("config.toml");
2946
2947        let original = r#"# top comment
2948model_provider = "openai"
2949
2950[model_providers.openai]
2951# keep this comment
2952name = "OpenAI"
2953base_url = "https://api.openai.com/v1"
2954request_max_retries = 3
2955
2956[projects."D:\\Work"]
2957trust_level = "trusted"
2958"#;
2959
2960        write_file(&cfg_path, original);
2961        switch_on(3211).expect("switch_on should preserve editable TOML structure");
2962
2963        let updated = read_file(&cfg_path);
2964        assert!(updated.contains("# top comment"));
2965        assert!(updated.contains("# keep this comment"));
2966        assert!(updated.contains("[model_providers.openai]"));
2967        assert!(updated.contains("[projects."));
2968        assert!(updated.contains("model_provider = \"codex_proxy\""));
2969        assert!(updated.contains("[model_providers.codex_proxy]"));
2970        assert!(updated.contains("base_url = \"http://127.0.0.1:3211\""));
2971    }
2972
2973    #[test]
2974    fn codex_switch_on_keeps_existing_proxy_retry_setting() {
2975        let text = r#"
2976model_provider = "codex_proxy"
2977
2978[model_providers.codex_proxy]
2979name = "custom"
2980base_url = "http://127.0.0.1:1111"
2981request_max_retries = 5
2982"#;
2983
2984        let updated = switch_on_codex_toml_with_mode(text, 3333, CodexPatchMode::Default)
2985            .expect("switch_on should update the local proxy provider in place");
2986
2987        assert!(updated.contains("request_max_retries = 5"));
2988        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
2989        assert!(updated.contains("name = \"codex-helper\""));
2990    }
2991
2992    #[test]
2993    fn codex_patch_plan_chatgpt_bridge_keeps_account_auth_shape_and_safe_write_order() {
2994        let plan = CodexPatchPlan::for_switch_on(
2995            CodexPatchMode::ChatGptBridge,
2996            CodexSwitchOptions::default(),
2997        )
2998        .expect("chatgpt bridge patch plan");
2999
3000        assert_eq!(plan.provider().provider_name(), "codex-helper");
3001        assert_eq!(plan.provider().requires_openai_auth().value(), Some(true));
3002        assert_eq!(plan.provider().supports_websockets().value(), Some(false));
3003        assert_eq!(plan.auth(), CodexAuthPatchPlan::PatchChatGptBridge);
3004        assert_eq!(
3005            plan.effect_order(),
3006            CodexSwitchOnEffectOrder::StateConfigAuth
3007        );
3008        assert!(
3009            !plan.requires_bridge_runtime_ready(),
3010            "chatgpt bridge validates the existing Codex auth.json instead of helper upstream auth"
3011        );
3012    }
3013
3014    #[test]
3015    fn codex_patch_plan_official_relay_uses_openai_identity_without_auth_facade() {
3016        let plan = CodexPatchPlan::for_switch_on(
3017            CodexPatchMode::OfficialRelayBridge,
3018            CodexSwitchOptions {
3019                responses_websocket: true,
3020                compaction: CodexCompactionStrategy::Auto,
3021            },
3022        )
3023        .expect("official relay patch plan");
3024
3025        assert_eq!(plan.provider().provider_name(), "OpenAI");
3026        assert_eq!(plan.provider().requires_openai_auth().value(), None);
3027        assert_eq!(plan.provider().supports_websockets().value(), Some(true));
3028        assert_eq!(
3029            plan.auth(),
3030            CodexAuthPatchPlan::RestoreOriginalIfHelperPatched
3031        );
3032        assert_eq!(
3033            plan.effect_order(),
3034            CodexSwitchOnEffectOrder::ConfigAuthState
3035        );
3036        assert!(plan.requires_bridge_runtime_ready());
3037    }
3038
3039    #[test]
3040    fn codex_patch_plan_official_imagegen_combines_openai_identity_and_auth_facade() {
3041        let plan = CodexPatchPlan::for_switch_on(
3042            CodexPatchMode::OfficialImagegenBridge,
3043            CodexSwitchOptions::default(),
3044        )
3045        .expect("official imagegen patch plan");
3046
3047        assert_eq!(plan.provider().provider_name(), "OpenAI");
3048        assert_eq!(plan.provider().requires_openai_auth().value(), None);
3049        assert_eq!(plan.provider().supports_websockets().value(), Some(false));
3050        assert_eq!(plan.auth(), CodexAuthPatchPlan::PatchImagegenFacade);
3051        assert_eq!(
3052            plan.effect_order(),
3053            CodexSwitchOnEffectOrder::StateConfigAuth
3054        );
3055        assert!(plan.requires_bridge_runtime_ready());
3056    }
3057
3058    #[test]
3059    fn codex_patch_plan_compaction_local_overrides_official_provider_identity() {
3060        let plan = CodexPatchPlan::for_switch_on(
3061            CodexPatchMode::OfficialImagegenBridge,
3062            CodexSwitchOptions {
3063                responses_websocket: false,
3064                compaction: CodexCompactionStrategy::Local,
3065            },
3066        )
3067        .expect("official imagegen local compaction patch plan");
3068
3069        assert_eq!(plan.provider().provider_name(), "codex-helper");
3070        assert_eq!(
3071            plan.remote_compaction_v2_feature(),
3072            CodexFeatureBoolPatch::Set(false)
3073        );
3074        assert_eq!(plan.auth(), CodexAuthPatchPlan::PatchImagegenFacade);
3075    }
3076
3077    #[test]
3078    fn codex_patch_plan_compaction_remote_v1_forces_openai_identity_and_disables_v2() {
3079        let plan = CodexPatchPlan::for_switch_on(
3080            CodexPatchMode::OfficialRelayBridge,
3081            CodexSwitchOptions {
3082                responses_websocket: false,
3083                compaction: CodexCompactionStrategy::RemoteV1,
3084            },
3085        )
3086        .expect("official relay remote v1 patch plan");
3087
3088        assert_eq!(plan.provider().provider_name(), "OpenAI");
3089        assert_eq!(
3090            plan.remote_compaction_v2_feature(),
3091            CodexFeatureBoolPatch::Set(false)
3092        );
3093    }
3094
3095    #[test]
3096    fn codex_patch_plan_compaction_remote_v2_forces_openai_identity_and_enables_v2() {
3097        let plan = CodexPatchPlan::for_switch_on(
3098            CodexPatchMode::OfficialRelayBridge,
3099            CodexSwitchOptions {
3100                responses_websocket: false,
3101                compaction: CodexCompactionStrategy::RemoteV2,
3102            },
3103        )
3104        .expect("official relay remote v2 patch plan");
3105
3106        assert_eq!(plan.provider().provider_name(), "OpenAI");
3107        assert_eq!(
3108            plan.remote_compaction_v2_feature(),
3109            CodexFeatureBoolPatch::Set(true)
3110        );
3111    }
3112
3113    #[test]
3114    fn codex_patch_plan_rejects_remote_compaction_without_official_preset() {
3115        let err = CodexPatchPlan::for_switch_on(
3116            CodexPatchMode::Default,
3117            CodexSwitchOptions {
3118                responses_websocket: false,
3119                compaction: CodexCompactionStrategy::RemoteV1,
3120            },
3121        )
3122        .expect_err("remote compaction should require an official preset");
3123
3124        assert!(
3125            err.to_string()
3126                .contains("remote compaction strategies require --preset official-relay")
3127        );
3128    }
3129
3130    #[test]
3131    fn codex_patch_plan_rejects_websocket_transport_without_official_identity() {
3132        let err = CodexPatchPlan::for_switch_on(
3133            CodexPatchMode::ImagegenBridge,
3134            CodexSwitchOptions {
3135                responses_websocket: true,
3136                compaction: CodexCompactionStrategy::Auto,
3137            },
3138        )
3139        .expect_err("websocket transport should be official identity only");
3140
3141        assert!(
3142            err.to_string()
3143                .contains("Responses WebSocket transport requires remote compaction identity")
3144        );
3145    }
3146
3147    #[test]
3148    fn codex_patch_plan_rejects_websocket_transport_with_local_compaction() {
3149        let err = CodexPatchPlan::for_switch_on(
3150            CodexPatchMode::OfficialRelayBridge,
3151            CodexSwitchOptions {
3152                responses_websocket: true,
3153                compaction: CodexCompactionStrategy::Local,
3154            },
3155        )
3156        .expect_err("websocket transport should require remote identity");
3157
3158        assert!(
3159            err.to_string()
3160                .contains("Responses WebSocket transport requires remote compaction identity")
3161        );
3162    }
3163
3164    #[test]
3165    fn codex_switch_on_toml_is_driven_by_patch_plan() {
3166        let text = r#"
3167model_provider = "codex_proxy"
3168
3169[model_providers.codex_proxy]
3170name = "codex-helper"
3171base_url = "http://127.0.0.1:1111"
3172wire_api = "responses"
3173requires_openai_auth = true
3174supports_websockets = false
3175"#;
3176        let plan = CodexPatchPlan::for_switch_on(
3177            CodexPatchMode::OfficialRelayBridge,
3178            CodexSwitchOptions {
3179                responses_websocket: true,
3180                compaction: CodexCompactionStrategy::Auto,
3181            },
3182        )
3183        .expect("official relay patch plan");
3184
3185        let updated =
3186            switch_on_codex_toml_with_plan(text, 3333, plan).expect("plan-driven TOML patch");
3187
3188        assert!(updated.contains("name = \"OpenAI\""));
3189        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3190        assert!(updated.contains("supports_websockets = true"));
3191        assert!(!updated.contains("requires_openai_auth"));
3192    }
3193
3194    #[test]
3195    fn codex_auth_edit_for_switch_on_plan_restores_prior_facade_from_current_text() {
3196        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
3197        let mut state = CodexSwitchState {
3198            version: 2,
3199            patch_mode: Some(CodexPatchMode::OfficialImagegenBridge),
3200            responses_websocket: false,
3201            compaction: CodexCompactionStrategy::Auto,
3202            original_config_absent: false,
3203            original_model_provider: None,
3204            original_codex_proxy: None,
3205            had_model_providers: false,
3206            original_auth_json_absent: false,
3207            original_auth_json: Some(original_auth.clone()),
3208            patched_auth_json: Some("{}".to_string()),
3209        };
3210        let plan = CodexPatchPlan::for_switch_on(
3211            CodexPatchMode::OfficialRelayBridge,
3212            CodexSwitchOptions::default(),
3213        )
3214        .expect("official relay patch plan");
3215
3216        let edit = auth_restore_edit_from_state_and_current(&mut state, Some("{}"));
3217
3218        assert_eq!(
3219            plan.auth(),
3220            CodexAuthPatchPlan::RestoreOriginalIfHelperPatched
3221        );
3222        assert_eq!(edit, CodexAuthEdit::Write(original_auth));
3223        assert_eq!(state.patched_auth_json, None);
3224    }
3225
3226    #[test]
3227    fn codex_switch_on_chatgpt_bridge_sets_openai_auth_flags() {
3228        let updated = switch_on_codex_toml_with_mode("", 3333, CodexPatchMode::ChatGptBridge)
3229            .expect("switch_on should write chatgpt bridge fields");
3230
3231        assert!(updated.contains("model_provider = \"codex_proxy\""));
3232        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3233        assert!(updated.contains("requires_openai_auth = true"));
3234        assert!(updated.contains("supports_websockets = false"));
3235    }
3236
3237    #[test]
3238    fn codex_switch_on_imagegen_bridge_uses_default_proxy_flags() {
3239        let updated = switch_on_codex_toml_with_mode("", 3333, CodexPatchMode::ImagegenBridge)
3240            .expect("switch_on should write imagegen bridge fields");
3241
3242        assert!(updated.contains("model_provider = \"codex_proxy\""));
3243        assert!(updated.contains("name = \"codex-helper\""));
3244        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3245        assert!(!updated.contains("requires_openai_auth"));
3246        assert!(!updated.contains("supports_websockets"));
3247    }
3248
3249    #[test]
3250    fn codex_switch_on_official_relay_bridge_sets_openai_name_and_disables_websockets() {
3251        let updated = switch_on_codex_toml_with_mode("", 3333, CodexPatchMode::OfficialRelayBridge)
3252            .expect("switch_on should write official relay preset fields");
3253
3254        assert!(updated.contains("model_provider = \"codex_proxy\""));
3255        assert!(updated.contains("name = \"OpenAI\""));
3256        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3257        assert!(updated.contains("wire_api = \"responses\""));
3258        assert!(updated.contains("supports_websockets = false"));
3259        assert!(!updated.contains("requires_openai_auth"));
3260    }
3261
3262    #[test]
3263    fn codex_switch_on_official_relay_bridge_can_enable_responses_websocket() {
3264        let updated = switch_on_codex_toml_with_options(
3265            "",
3266            3333,
3267            CodexPatchMode::OfficialRelayBridge,
3268            CodexSwitchOptions {
3269                responses_websocket: true,
3270                compaction: CodexCompactionStrategy::Auto,
3271            },
3272        )
3273        .expect("switch_on should write official relay preset fields with websocket transport");
3274
3275        assert!(updated.contains("model_provider = \"codex_proxy\""));
3276        assert!(updated.contains("name = \"OpenAI\""));
3277        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3278        assert!(updated.contains("wire_api = \"responses\""));
3279        assert!(updated.contains("supports_websockets = true"));
3280        assert!(!updated.contains("requires_openai_auth"));
3281    }
3282
3283    #[test]
3284    fn codex_switch_on_official_imagegen_bridge_sets_openai_name_and_disables_websockets() {
3285        let updated =
3286            switch_on_codex_toml_with_mode("", 3333, CodexPatchMode::OfficialImagegenBridge)
3287                .expect("switch_on should write official imagegen preset fields");
3288
3289        assert!(updated.contains("model_provider = \"codex_proxy\""));
3290        assert!(updated.contains("name = \"OpenAI\""));
3291        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3292        assert!(updated.contains("wire_api = \"responses\""));
3293        assert!(updated.contains("supports_websockets = false"));
3294        assert!(!updated.contains("requires_openai_auth"));
3295    }
3296
3297    #[test]
3298    fn codex_switch_on_official_imagegen_bridge_can_enable_responses_websocket() {
3299        let updated = switch_on_codex_toml_with_options(
3300            "",
3301            3333,
3302            CodexPatchMode::OfficialImagegenBridge,
3303            CodexSwitchOptions {
3304                responses_websocket: true,
3305                compaction: CodexCompactionStrategy::Auto,
3306            },
3307        )
3308        .expect("switch_on should write official imagegen preset fields with websocket transport");
3309
3310        assert!(updated.contains("model_provider = \"codex_proxy\""));
3311        assert!(updated.contains("name = \"OpenAI\""));
3312        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3313        assert!(updated.contains("wire_api = \"responses\""));
3314        assert!(updated.contains("supports_websockets = true"));
3315        assert!(!updated.contains("requires_openai_auth"));
3316    }
3317
3318    #[test]
3319    fn codex_switch_on_can_disable_hosted_image_generation_feature() {
3320        let plan = CodexPatchPlan::for_switch_on(
3321            CodexPatchMode::OfficialImagegenBridge,
3322            CodexSwitchOptions::default(),
3323        )
3324        .expect("plan");
3325        let updated = switch_on_codex_toml_with_plan_and_hosted_image_generation(
3326            "",
3327            3333,
3328            plan,
3329            CodexHostedImageGenerationMode::Disabled,
3330        )
3331        .expect("switch_on should write image generation feature flag");
3332
3333        assert!(updated.contains("[features]"));
3334        assert!(updated.contains("image_generation = false"));
3335    }
3336
3337    #[test]
3338    fn codex_switch_on_official_imagegen_can_force_local_compaction() {
3339        let updated = switch_on_codex_toml_with_options(
3340            r#"
3341[features]
3342remote_compaction_v2 = true
3343"#,
3344            3333,
3345            CodexPatchMode::OfficialImagegenBridge,
3346            CodexSwitchOptions {
3347                responses_websocket: false,
3348                compaction: CodexCompactionStrategy::Local,
3349            },
3350        )
3351        .expect("switch_on should force local compaction");
3352
3353        assert!(updated.contains("name = \"codex-helper\""));
3354        assert!(updated.contains("remote_compaction_v2 = false"));
3355        assert!(!updated.contains("supports_websockets = true"));
3356        assert!(!updated.contains("requires_openai_auth"));
3357    }
3358
3359    #[test]
3360    fn codex_switch_on_official_relay_can_force_remote_compaction_v1() {
3361        let updated = switch_on_codex_toml_with_options(
3362            r#"
3363[features]
3364remote_compaction_v2 = true
3365"#,
3366            3333,
3367            CodexPatchMode::OfficialRelayBridge,
3368            CodexSwitchOptions {
3369                responses_websocket: false,
3370                compaction: CodexCompactionStrategy::RemoteV1,
3371            },
3372        )
3373        .expect("switch_on should force remote compaction v1");
3374
3375        assert!(updated.contains("name = \"OpenAI\""));
3376        assert!(updated.contains("remote_compaction_v2 = false"));
3377        assert!(updated.contains("supports_websockets = false"));
3378    }
3379
3380    #[test]
3381    fn codex_switch_on_official_relay_can_force_remote_compaction_v2() {
3382        let updated = switch_on_codex_toml_with_options(
3383            "",
3384            3333,
3385            CodexPatchMode::OfficialRelayBridge,
3386            CodexSwitchOptions {
3387                responses_websocket: false,
3388                compaction: CodexCompactionStrategy::RemoteV2,
3389            },
3390        )
3391        .expect("switch_on should force remote compaction v2");
3392
3393        assert!(updated.contains("name = \"OpenAI\""));
3394        assert!(updated.contains("[features]"));
3395        assert!(updated.contains("remote_compaction_v2 = true"));
3396        assert!(updated.contains("supports_websockets = false"));
3397    }
3398
3399    #[test]
3400    fn empty_auth_json_facade_detection_uses_json_semantics() {
3401        assert!(auth_json_is_empty_chatgpt_facade_text(Some("{}")));
3402        assert!(auth_json_is_empty_chatgpt_facade_text(Some("{\n}\n")));
3403        assert!(!auth_json_is_empty_chatgpt_facade_text(Some(
3404            r#"{"auth_mode":"chatgpt"}"#
3405        )));
3406        assert!(!auth_json_is_empty_chatgpt_facade_text(None));
3407        assert!(!auth_json_is_empty_chatgpt_facade_text(Some("not-json")));
3408    }
3409
3410    #[test]
3411    fn codex_switch_on_default_removes_bridge_only_flags() {
3412        let text = r#"
3413model_provider = "codex_proxy"
3414
3415[model_providers.codex_proxy]
3416name = "codex-helper"
3417base_url = "http://127.0.0.1:1111"
3418wire_api = "responses"
3419requires_openai_auth = true
3420supports_websockets = false
3421"#;
3422
3423        let updated = switch_on_codex_toml_with_mode(text, 3333, CodexPatchMode::Default)
3424            .expect("switch_on should switch local proxy back to default mode");
3425
3426        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3427        assert!(!updated.contains("requires_openai_auth"));
3428        assert!(!updated.contains("supports_websockets"));
3429    }
3430
3431    #[test]
3432    fn remote_control_config_patch_sets_remote_connections_and_removes_removed_key() {
3433        let text = r#"
3434[features]
3435remote_control = true
3436apps = true
3437"#;
3438
3439        let updated = ensure_codex_remote_connections_feature_in_toml(text)
3440            .expect("remote-control config patch should parse");
3441
3442        assert!(updated.contains("remote_connections = true"));
3443        assert!(updated.contains("apps = true"));
3444        assert!(!updated.contains("remote_control = true"));
3445        assert!(
3446            codex_remote_connections_feature_enabled_from_toml(&updated)
3447                .expect("remote_connections should parse")
3448        );
3449        assert!(
3450            !codex_remote_control_feature_present_in_toml(&updated)
3451                .expect("remote_control should parse")
3452        );
3453    }
3454
3455    #[test]
3456    fn codex_remote_control_enable_patches_config_backs_up_db_and_writes_sqlite() {
3457        let env = setup_temp_env();
3458        let cfg_path = env.codex_home.join("config.toml");
3459        let db_dir = env.codex_home.join("sqlite");
3460        let db_path = db_dir.join("codex-dev.db");
3461        std::fs::create_dir_all(&db_dir).expect("create sqlite dir");
3462        write_file(
3463            &cfg_path,
3464            r#"
3465[features]
3466remote_control = true
3467"#
3468            .trim_start(),
3469        );
3470
3471        {
3472            let conn = rusqlite::Connection::open(&db_path).expect("open test sqlite");
3473            conn.execute(
3474                "CREATE TABLE local_app_server_feature_enablement (
3475                    feature_name TEXT PRIMARY KEY,
3476                    enabled INTEGER NOT NULL,
3477                    updated_at INTEGER NOT NULL
3478                )",
3479                [],
3480            )
3481            .expect("create feature table");
3482            conn.execute(
3483                "INSERT INTO local_app_server_feature_enablement (feature_name, enabled, updated_at)
3484                 VALUES (?1, ?2, ?3)",
3485                rusqlite::params!["remote_control", 0_i64, 7_i64],
3486            )
3487            .expect("seed feature row");
3488        }
3489
3490        let result =
3491            codex_remote_control_enable().expect("remote-control enablement should succeed");
3492
3493        assert!(result.backup_path.exists(), "backup should be created");
3494        assert!(result.status.remote_connections_enabled);
3495        assert!(!result.status.remote_control_config_present);
3496        assert_eq!(result.status.db_enabled, Some(true));
3497        assert!(result.status.db_updated_at.unwrap_or_default() > 7);
3498
3499        let updated_cfg = read_file(&cfg_path);
3500        assert!(updated_cfg.contains("remote_connections = true"));
3501        assert!(!updated_cfg.contains("remote_control = true"));
3502    }
3503
3504    #[test]
3505    fn codex_remote_control_enable_does_not_patch_config_when_db_schema_is_missing() {
3506        let env = setup_temp_env();
3507        let cfg_path = env.codex_home.join("config.toml");
3508        let db_dir = env.codex_home.join("sqlite");
3509        let db_path = db_dir.join("codex-dev.db");
3510        std::fs::create_dir_all(&db_dir).expect("create sqlite dir");
3511        write_file(
3512            &cfg_path,
3513            r#"
3514[features]
3515apps = true
3516"#
3517            .trim_start(),
3518        );
3519        {
3520            let _conn = rusqlite::Connection::open(&db_path).expect("open empty test sqlite");
3521        }
3522        let original = read_file(&cfg_path);
3523
3524        let err = codex_remote_control_enable()
3525            .expect_err("remote-control enablement should reject missing feature table");
3526
3527        assert!(
3528            err.to_string()
3529                .contains("local_app_server_feature_enablement")
3530        );
3531        assert_eq!(read_file(&cfg_path), original);
3532    }
3533
3534    #[test]
3535    fn remote_control_log_scan_detects_successful_enablement_set() {
3536        let env = setup_temp_env();
3537        let log_dir = env.codex_home.join("log");
3538        std::fs::create_dir_all(&log_dir).expect("create log dir");
3539        write_file(
3540            &log_dir.join("codex-app.log"),
3541            r#"{"method":"experimentalFeature/enablement/set","errorCode":null}"#,
3542        );
3543
3544        assert!(
3545            codex_remote_control_successful_enablement_log_seen_in_dir(&log_dir)
3546                .expect("scan logs")
3547        );
3548    }
3549
3550    fn startup_readiness_input(changed: bool) -> CodexStartupReadinessInput {
3551        CodexStartupReadinessInput {
3552            expected_port: 3211,
3553            expected_patch_mode: CodexPatchMode::Default,
3554            expected_responses_websocket: false,
3555            client_state_changed_this_startup: changed,
3556            switch_error: None,
3557        }
3558    }
3559
3560    fn has_startup_issue(
3561        report: &CodexStartupReadiness,
3562        kind: CodexStartupReadinessIssueKind,
3563    ) -> bool {
3564        report.issues.iter().any(|issue| issue.kind == kind)
3565    }
3566
3567    #[test]
3568    fn codex_tui_startup_readiness_is_quiet_when_switch_is_ready() {
3569        let env = setup_temp_env();
3570        let cfg_path = env.codex_home.join("config.toml");
3571        write_file(
3572            &cfg_path,
3573            r#"
3574model_provider = "openai"
3575
3576[model_providers.openai]
3577name = "openai"
3578base_url = "https://api.openai.com/v1"
3579"#
3580            .trim_start(),
3581        );
3582        switch_on(3211).expect("switch_on should create a valid local proxy patch");
3583
3584        let report = codex_tui_startup_readiness(startup_readiness_input(false));
3585
3586        assert_eq!(report.issues, Vec::new());
3587    }
3588
3589    #[test]
3590    fn codex_tui_startup_readiness_reports_client_state_changed() {
3591        let env = setup_temp_env();
3592        let cfg_path = env.codex_home.join("config.toml");
3593        write_file(
3594            &cfg_path,
3595            r#"
3596model_provider = "openai"
3597
3598[model_providers.openai]
3599name = "openai"
3600base_url = "https://api.openai.com/v1"
3601"#
3602            .trim_start(),
3603        );
3604        switch_on(3211).expect("switch_on should create a valid local proxy patch");
3605
3606        let report = codex_tui_startup_readiness(startup_readiness_input(true));
3607
3608        assert!(has_startup_issue(
3609            &report,
3610            CodexStartupReadinessIssueKind::ClientStateChanged
3611        ));
3612        assert_eq!(report.issues.len(), 1);
3613    }
3614
3615    #[test]
3616    fn codex_tui_startup_readiness_warns_for_local_proxy_without_switch_state() {
3617        let env = setup_temp_env();
3618        let cfg_path = env.codex_home.join("config.toml");
3619        write_file(
3620            &cfg_path,
3621            r#"
3622model_provider = "codex_proxy"
3623
3624[model_providers.codex_proxy]
3625name = "codex-helper"
3626base_url = "http://127.0.0.1:3211"
3627wire_api = "responses"
3628"#
3629            .trim_start(),
3630        );
3631
3632        let report = codex_tui_startup_readiness(startup_readiness_input(false));
3633
3634        assert!(has_startup_issue(
3635            &report,
3636            CodexStartupReadinessIssueKind::MissingSwitchState
3637        ));
3638    }
3639
3640    #[test]
3641    fn codex_tui_startup_readiness_warns_official_bridge_with_preferred_group_multi_provider() {
3642        let env = setup_temp_env();
3643        write_helper_codex_config(
3644            &env,
3645            r#"
3646version = 5
3647
3648[codex.providers.a]
3649base_url = "https://a.example/v1"
3650auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
3651
3652[codex.providers.b]
3653base_url = "https://b.example/v1"
3654auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
3655
3656[codex.routing]
3657entry = "main"
3658affinity_policy = "preferred-group"
3659
3660[codex.routing.routes.main]
3661strategy = "ordered-failover"
3662children = ["a", "b"]
3663"#,
3664        );
3665        let cfg_path = env.codex_home.join("config.toml");
3666        write_file(
3667            &cfg_path,
3668            r#"
3669model_provider = "codex_proxy"
3670
3671[model_providers.codex_proxy]
3672name = "OpenAI"
3673base_url = "http://127.0.0.1:3211"
3674wire_api = "responses"
3675supports_websockets = false
3676"#
3677            .trim_start(),
3678        );
3679
3680        let mut input = startup_readiness_input(false);
3681        input.expected_patch_mode = CodexPatchMode::OfficialRelayBridge;
3682        let report = codex_tui_startup_readiness(input);
3683
3684        assert!(has_startup_issue(
3685            &report,
3686            CodexStartupReadinessIssueKind::OfficialRelayAffinityPolicy
3687        ));
3688    }
3689
3690    #[test]
3691    fn codex_tui_startup_readiness_accepts_official_bridge_with_fallback_sticky() {
3692        let env = setup_temp_env();
3693        write_helper_codex_config(
3694            &env,
3695            r#"
3696version = 5
3697
3698[codex.providers.a]
3699base_url = "https://a.example/v1"
3700auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
3701
3702[codex.providers.b]
3703base_url = "https://b.example/v1"
3704auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
3705
3706[codex.routing]
3707entry = "main"
3708affinity_policy = "fallback-sticky"
3709
3710[codex.routing.routes.main]
3711strategy = "ordered-failover"
3712children = ["a", "b"]
3713"#,
3714        );
3715        let cfg_path = env.codex_home.join("config.toml");
3716        write_file(
3717            &cfg_path,
3718            r#"
3719model_provider = "codex_proxy"
3720
3721[model_providers.codex_proxy]
3722name = "OpenAI"
3723base_url = "http://127.0.0.1:3211"
3724wire_api = "responses"
3725supports_websockets = false
3726"#
3727            .trim_start(),
3728        );
3729
3730        let mut input = startup_readiness_input(false);
3731        input.expected_patch_mode = CodexPatchMode::OfficialRelayBridge;
3732        let report = codex_tui_startup_readiness(input);
3733
3734        assert!(!has_startup_issue(
3735            &report,
3736            CodexStartupReadinessIssueKind::OfficialRelayAffinityPolicy
3737        ));
3738    }
3739
3740    #[test]
3741    fn codex_tui_startup_readiness_warns_when_remote_control_log_is_unconfirmed() {
3742        let env = setup_temp_env();
3743        let cfg_path = env.codex_home.join("config.toml");
3744        let db_dir = env.codex_home.join("sqlite");
3745        let db_path = db_dir.join("codex-dev.db");
3746        std::fs::create_dir_all(&db_dir).expect("create sqlite dir");
3747        write_file(
3748            &cfg_path,
3749            r#"
3750model_provider = "openai"
3751
3752[features]
3753remote_connections = true
3754
3755[model_providers.openai]
3756name = "openai"
3757base_url = "https://api.openai.com/v1"
3758"#
3759            .trim_start(),
3760        );
3761        {
3762            let conn = rusqlite::Connection::open(&db_path).expect("open test sqlite");
3763            conn.execute(
3764                "CREATE TABLE local_app_server_feature_enablement (
3765                    feature_name TEXT PRIMARY KEY,
3766                    enabled INTEGER NOT NULL,
3767                    updated_at INTEGER NOT NULL
3768                )",
3769                [],
3770            )
3771            .expect("create feature table");
3772            conn.execute(
3773                "INSERT INTO local_app_server_feature_enablement (feature_name, enabled, updated_at)
3774                 VALUES (?1, ?2, ?3)",
3775                rusqlite::params!["remote_control", 1_i64, 7_i64],
3776            )
3777            .expect("seed feature row");
3778        }
3779        switch_on(3211).expect("switch_on should preserve features");
3780
3781        let report = codex_tui_startup_readiness(startup_readiness_input(false));
3782
3783        assert!(has_startup_issue(
3784            &report,
3785            CodexStartupReadinessIssueKind::RemoteControlLogUnconfirmed
3786        ));
3787    }
3788
3789    #[test]
3790    fn chatgpt_bridge_auth_patch_preserves_other_auth_json_fields() {
3791        let mut input: serde_json::Value =
3792            serde_json::from_str(&chatgpt_auth_json("user@example.com", "account-1", "plus"))
3793                .expect("valid fixture");
3794        input["last_refresh"] = json!(123);
3795        input["unrelated"] = json!("keep");
3796
3797        let updated = chatgpt_bridge_auth_json_text(&serde_json::to_string_pretty(&input).unwrap())
3798            .expect("auth json patch should preserve unrelated fields");
3799        let value: serde_json::Value = serde_json::from_str(&updated).expect("valid json");
3800        let object = value.as_object().expect("root object");
3801
3802        assert_eq!(
3803            object.get("auth_mode").and_then(|value| value.as_str()),
3804            Some("chatgpt")
3805        );
3806        assert!(
3807            object
3808                .get("OPENAI_API_KEY")
3809                .is_some_and(|value| value.is_null())
3810        );
3811        assert_eq!(
3812            object
3813                .get("tokens")
3814                .and_then(|value| value.get("access_token"))
3815                .and_then(|value| value.as_str()),
3816            Some("chatgpt-access-token")
3817        );
3818        assert_eq!(
3819            object
3820                .get("tokens")
3821                .and_then(|value| value.get("account_id"))
3822                .and_then(|value| value.as_str()),
3823            Some("account-1")
3824        );
3825        assert_eq!(
3826            object.get("last_refresh").and_then(|value| value.as_i64()),
3827            Some(123)
3828        );
3829        assert_eq!(
3830            object.get("unrelated").and_then(|value| value.as_str()),
3831            Some("keep")
3832        );
3833    }
3834
3835    #[test]
3836    fn chatgpt_bridge_auth_patch_rejects_incomplete_login_state() {
3837        let err = chatgpt_bridge_auth_json_text(r#"{"auth_mode":"chatgpt","OPENAI_API_KEY":null}"#)
3838            .expect_err("empty ChatGPT auth state should be rejected");
3839
3840        let message = err.to_string();
3841        assert!(message.contains("complete ChatGPT login state"));
3842        assert!(message.contains("tokens.id_token"));
3843        assert!(message.contains("tokens.access_token"));
3844        assert!(message.contains("last_refresh"));
3845    }
3846
3847    #[test]
3848    fn chatgpt_bridge_auth_patch_rejects_api_key_only_auth() {
3849        let err = chatgpt_bridge_auth_json_text(
3850            r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-old","account_id":"acct_1"}"#,
3851        )
3852        .expect_err("API key auth should not be converted into fake ChatGPT auth");
3853
3854        assert!(
3855            err.to_string()
3856                .contains("Open Codex and sign in with ChatGPT first")
3857        );
3858    }
3859
3860    #[test]
3861    fn chatgpt_bridge_auth_patch_accepts_chatgpt_auth_without_plan_claim() {
3862        let input = chatgpt_auth_json_without_plan("user@example.com", "acct_1");
3863
3864        let updated = chatgpt_bridge_auth_json_text(&input)
3865            .expect("Codex maps missing ChatGPT plan claims to unknown");
3866        let value: serde_json::Value = serde_json::from_str(&updated).expect("valid json");
3867
3868        assert_eq!(
3869            value.get("auth_mode").and_then(|value| value.as_str()),
3870            Some("chatgpt")
3871        );
3872        assert!(
3873            value
3874                .get("OPENAI_API_KEY")
3875                .is_some_and(|value| value.is_null())
3876        );
3877    }
3878
3879    #[test]
3880    fn imagegen_bridge_auth_patch_writes_empty_chatgpt_facade() {
3881        let updated = imagegen_bridge_auth_json_text().expect("serialize facade auth");
3882        let value: serde_json::Value = serde_json::from_str(&updated).expect("valid json");
3883
3884        assert!(
3885            value.as_object().is_some_and(serde_json::Map::is_empty),
3886            "imagegen bridge must rely on Codex's default ChatGPT mode fallback, not an explicit auth_mode field"
3887        );
3888    }
3889
3890    #[test]
3891    fn helper_auth_patch_match_uses_json_semantics() {
3892        assert!(auth_json_matches_helper_patch(
3893            Some(r#"{"auth_mode":"chatgpt"}"#),
3894            "{\n  \"auth_mode\": \"chatgpt\"\n}"
3895        ));
3896        assert!(!auth_json_matches_helper_patch(
3897            Some(r#"{"auth_mode":"apikey"}"#),
3898            "{\n  \"auth_mode\": \"chatgpt\"\n}"
3899        ));
3900    }
3901
3902    #[test]
3903    fn imagegen_bridge_ready_check_rejects_missing_codex_upstreams() {
3904        let _env = setup_temp_env();
3905
3906        let err = ensure_imagegen_bridge_runtime_ready()
3907            .expect_err("imagegen bridge should require codex-helper upstreams");
3908
3909        assert!(
3910            err.to_string()
3911                .contains("requires at least one enabled Codex upstream")
3912        );
3913    }
3914
3915    #[test]
3916    fn imagegen_bridge_ready_check_rejects_unresolved_upstream_env() {
3917        let env = setup_temp_env();
3918        write_helper_codex_config(
3919            &env,
3920            r#"
3921version = 5
3922
3923[codex.providers.relay]
3924base_url = "https://relay.example/v1"
3925auth_token_env = "CODEX_HELPER_MISSING_IMAGEGEN_KEY"
3926
3927[codex.routing]
3928entry = "main"
3929
3930[codex.routing.routes.main]
3931strategy = "ordered-failover"
3932children = ["relay"]
3933"#,
3934        );
3935
3936        let err = ensure_imagegen_bridge_runtime_ready()
3937            .expect_err("imagegen bridge should require resolved upstream auth");
3938
3939        let message = err.to_string();
3940        assert!(message.contains("no enabled Codex upstream credential is available"));
3941        assert!(message.contains("CODEX_HELPER_MISSING_IMAGEGEN_KEY"));
3942    }
3943
3944    #[test]
3945    fn official_relay_bridge_ready_check_rejects_unresolved_upstream_env() {
3946        let env = setup_temp_env();
3947        write_helper_codex_config(
3948            &env,
3949            r#"
3950version = 5
3951
3952[codex.providers.relay]
3953base_url = "https://relay.example/v1"
3954auth_token_env = "CODEX_HELPER_MISSING_IMAGEGEN_KEY"
3955
3956[codex.routing]
3957entry = "main"
3958
3959[codex.routing.routes.main]
3960strategy = "ordered-failover"
3961children = ["relay"]
3962"#,
3963        );
3964
3965        let err = ensure_bridge_runtime_ready(CodexPatchMode::OfficialRelayBridge)
3966            .expect_err("official relay preset should require resolved upstream auth");
3967
3968        let message = err.to_string();
3969        assert!(message.contains("official-relay"));
3970        assert!(message.contains("no enabled Codex upstream credential is available"));
3971        assert!(message.contains("CODEX_HELPER_MISSING_IMAGEGEN_KEY"));
3972    }
3973
3974    #[test]
3975    fn official_imagegen_bridge_ready_check_rejects_unresolved_upstream_env() {
3976        let env = setup_temp_env();
3977        write_helper_codex_config(
3978            &env,
3979            r#"
3980version = 5
3981
3982[codex.providers.relay]
3983base_url = "https://relay.example/v1"
3984auth_token_env = "CODEX_HELPER_MISSING_IMAGEGEN_KEY"
3985
3986[codex.routing]
3987entry = "main"
3988
3989[codex.routing.routes.main]
3990strategy = "ordered-failover"
3991children = ["relay"]
3992"#,
3993        );
3994
3995        let err = ensure_bridge_runtime_ready(CodexPatchMode::OfficialImagegenBridge)
3996            .expect_err("official imagegen preset should require resolved upstream auth");
3997
3998        let message = err.to_string();
3999        assert!(message.contains("official-imagegen"));
4000        assert!(message.contains("no enabled Codex upstream credential is available"));
4001        assert!(message.contains("CODEX_HELPER_MISSING_IMAGEGEN_KEY"));
4002    }
4003
4004    #[test]
4005    fn official_relay_bridge_with_responses_websocket_ready_check_rejects_unresolved_upstream_env()
4006    {
4007        let env = setup_temp_env();
4008        write_helper_codex_config(
4009            &env,
4010            r#"
4011version = 5
4012
4013[codex.providers.relay]
4014base_url = "https://relay.example/v1"
4015auth_token_env = "CODEX_HELPER_MISSING_IMAGEGEN_KEY"
4016
4017[codex.routing]
4018entry = "main"
4019
4020[codex.routing.routes.main]
4021strategy = "ordered-failover"
4022children = ["relay"]
4023"#,
4024        );
4025
4026        let err = switch_on_with_options(
4027            3211,
4028            CodexPatchMode::OfficialRelayBridge,
4029            CodexSwitchOptions {
4030                responses_websocket: true,
4031                compaction: CodexCompactionStrategy::Auto,
4032            },
4033        )
4034        .expect_err("official relay preset with websocket should require resolved upstream auth");
4035
4036        let message = err.to_string();
4037        assert!(message.contains("official-relay"));
4038        assert!(message.contains("no enabled Codex upstream credential is available"));
4039        assert!(message.contains("CODEX_HELPER_MISSING_IMAGEGEN_KEY"));
4040    }
4041
4042    #[test]
4043    fn codex_switch_on_chatgpt_bridge_patches_auth_json() {
4044        let env = setup_temp_env();
4045        let cfg_path = env.codex_home.join("config.toml");
4046        let auth_path = env.codex_home.join("auth.json");
4047
4048        write_file(
4049            &cfg_path,
4050            r#"
4051model_provider = "openai"
4052
4053[model_providers.openai]
4054name = "openai"
4055base_url = "https://api.openai.com/v1"
4056"#
4057            .trim_start(),
4058        );
4059        write_file(
4060            &auth_path,
4061            &chatgpt_auth_json("user@example.com", "acct_1", "plus"),
4062        );
4063
4064        switch_on_with_mode(3211, CodexPatchMode::ChatGptBridge)
4065            .expect("switch_on bridge should patch config and auth");
4066
4067        let updated_cfg = read_file(&cfg_path);
4068        assert!(updated_cfg.contains("requires_openai_auth = true"));
4069        assert!(updated_cfg.contains("supports_websockets = false"));
4070
4071        let updated_auth: serde_json::Value =
4072            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
4073        assert_eq!(
4074            updated_auth
4075                .get("auth_mode")
4076                .and_then(|value| value.as_str()),
4077            Some("chatgpt")
4078        );
4079        assert!(
4080            updated_auth
4081                .get("OPENAI_API_KEY")
4082                .is_some_and(|value| value.is_null())
4083        );
4084        assert_eq!(
4085            updated_auth
4086                .get("tokens")
4087                .and_then(|value| value.get("account_id"))
4088                .and_then(|value| value.as_str()),
4089            Some("acct_1")
4090        );
4091    }
4092
4093    #[test]
4094    fn codex_switch_on_imagegen_bridge_patches_auth_json_and_records_mode() {
4095        let env = setup_temp_env();
4096        write_helper_codex_config_with_env_auth(&env);
4097        let cfg_path = env.codex_home.join("config.toml");
4098        let auth_path = env.codex_home.join("auth.json");
4099        let state_path = env.codex_home.join("codex-helper-switch-state.json");
4100        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4101
4102        write_file(
4103            &cfg_path,
4104            r#"
4105model_provider = "openai"
4106
4107[model_providers.openai]
4108name = "openai"
4109base_url = "https://api.openai.com/v1"
4110"#
4111            .trim_start(),
4112        );
4113        write_file(&auth_path, &original_auth);
4114
4115        switch_on_with_mode(3211, CodexPatchMode::ImagegenBridge)
4116            .expect("switch_on imagegen bridge should patch config and auth");
4117
4118        let updated_cfg = read_file(&cfg_path);
4119        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
4120        assert!(!updated_cfg.contains("requires_openai_auth"));
4121
4122        let updated_auth: serde_json::Value =
4123            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
4124        assert!(
4125            updated_auth
4126                .as_object()
4127                .is_some_and(serde_json::Map::is_empty)
4128        );
4129
4130        let state_text = read_file(&state_path);
4131        let state: serde_json::Value = serde_json::from_str(&state_text).expect("valid state");
4132        assert_eq!(
4133            state.get("patch_mode").and_then(|value| value.as_str()),
4134            Some("imagegen-bridge")
4135        );
4136        assert_eq!(
4137            state
4138                .get("original_auth_json")
4139                .and_then(|value| value.as_str()),
4140            Some(original_auth.as_str())
4141        );
4142        assert_eq!(
4143            state
4144                .get("patched_auth_json")
4145                .and_then(|value| value.as_str()),
4146            Some("{}")
4147        );
4148    }
4149
4150    #[test]
4151    fn codex_switch_on_official_relay_bridge_records_mode_without_auth_json_patch() {
4152        let env = setup_temp_env();
4153        write_helper_codex_config_with_env_auth(&env);
4154        let cfg_path = env.codex_home.join("config.toml");
4155        let auth_path = env.codex_home.join("auth.json");
4156        let state_path = env.codex_home.join("codex-helper-switch-state.json");
4157        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4158
4159        write_file(
4160            &cfg_path,
4161            r#"
4162model_provider = "openai"
4163
4164[model_providers.openai]
4165name = "openai"
4166base_url = "https://api.openai.com/v1"
4167"#
4168            .trim_start(),
4169        );
4170        write_file(&auth_path, &original_auth);
4171
4172        switch_on_with_mode(3211, CodexPatchMode::OfficialRelayBridge)
4173            .expect("switch_on official relay preset should patch config");
4174
4175        let updated_cfg = read_file(&cfg_path);
4176        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
4177        assert!(updated_cfg.contains("name = \"OpenAI\""));
4178        assert!(updated_cfg.contains("supports_websockets = false"));
4179        assert!(!updated_cfg.contains("requires_openai_auth"));
4180        assert_eq!(read_file(&auth_path), original_auth);
4181
4182        let state_text = read_file(&state_path);
4183        let state: serde_json::Value = serde_json::from_str(&state_text).expect("valid state");
4184        assert_eq!(
4185            state.get("patch_mode").and_then(|value| value.as_str()),
4186            Some("official-relay-bridge")
4187        );
4188        assert!(state.get("patched_auth_json").is_none());
4189
4190        let status = codex_switch_status().expect("status should load");
4191        assert_eq!(status.patch_mode, Some(CodexPatchMode::OfficialRelayBridge));
4192        assert_eq!(status.requires_openai_auth, None);
4193        assert_eq!(status.supports_websockets, Some(false));
4194    }
4195
4196    #[test]
4197    fn codex_switch_on_official_relay_bridge_records_transport_option_without_auth_json_patch() {
4198        let env = setup_temp_env();
4199        write_helper_codex_config_with_env_auth(&env);
4200        let cfg_path = env.codex_home.join("config.toml");
4201        let auth_path = env.codex_home.join("auth.json");
4202        let state_path = env.codex_home.join("codex-helper-switch-state.json");
4203        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4204
4205        write_file(
4206            &cfg_path,
4207            r#"
4208model_provider = "openai"
4209
4210[model_providers.openai]
4211name = "openai"
4212base_url = "https://api.openai.com/v1"
4213"#
4214            .trim_start(),
4215        );
4216        write_file(&auth_path, &original_auth);
4217
4218        switch_on_with_options(
4219            3211,
4220            CodexPatchMode::OfficialRelayBridge,
4221            CodexSwitchOptions {
4222                responses_websocket: true,
4223                compaction: CodexCompactionStrategy::Auto,
4224            },
4225        )
4226        .expect("switch_on official relay preset should patch config with websocket transport");
4227
4228        let updated_cfg = read_file(&cfg_path);
4229        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
4230        assert!(updated_cfg.contains("name = \"OpenAI\""));
4231        assert!(updated_cfg.contains("supports_websockets = true"));
4232        assert!(!updated_cfg.contains("requires_openai_auth"));
4233        assert_eq!(read_file(&auth_path), original_auth);
4234
4235        let state_text = read_file(&state_path);
4236        let state: serde_json::Value = serde_json::from_str(&state_text).expect("valid state");
4237        assert_eq!(
4238            state.get("patch_mode").and_then(|value| value.as_str()),
4239            Some("official-relay-bridge")
4240        );
4241        assert_eq!(
4242            state
4243                .get("responses_websocket")
4244                .and_then(|value| value.as_bool()),
4245            Some(true)
4246        );
4247        assert!(state.get("patched_auth_json").is_none());
4248
4249        let status = codex_switch_status().expect("status should load");
4250        assert_eq!(status.patch_mode, Some(CodexPatchMode::OfficialRelayBridge));
4251        assert_eq!(status.requires_openai_auth, None);
4252        assert_eq!(status.supports_websockets, Some(true));
4253    }
4254
4255    #[test]
4256    fn codex_switch_on_official_imagegen_bridge_records_mode_and_patches_auth_json() {
4257        let env = setup_temp_env();
4258        write_helper_codex_config_with_env_auth(&env);
4259        let cfg_path = env.codex_home.join("config.toml");
4260        let auth_path = env.codex_home.join("auth.json");
4261        let state_path = env.codex_home.join("codex-helper-switch-state.json");
4262        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4263
4264        write_file(
4265            &cfg_path,
4266            r#"
4267model_provider = "openai"
4268
4269[model_providers.openai]
4270name = "openai"
4271base_url = "https://api.openai.com/v1"
4272"#
4273            .trim_start(),
4274        );
4275        write_file(&auth_path, &original_auth);
4276
4277        switch_on_with_mode(3211, CodexPatchMode::OfficialImagegenBridge)
4278            .expect("switch_on official imagegen preset should patch config and auth");
4279
4280        let updated_cfg = read_file(&cfg_path);
4281        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
4282        assert!(updated_cfg.contains("name = \"OpenAI\""));
4283        assert!(updated_cfg.contains("supports_websockets = false"));
4284        assert!(!updated_cfg.contains("requires_openai_auth"));
4285
4286        let updated_auth: serde_json::Value =
4287            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
4288        assert!(
4289            updated_auth
4290                .as_object()
4291                .is_some_and(serde_json::Map::is_empty)
4292        );
4293
4294        let state_text = read_file(&state_path);
4295        let state: serde_json::Value = serde_json::from_str(&state_text).expect("valid state");
4296        assert_eq!(
4297            state.get("patch_mode").and_then(|value| value.as_str()),
4298            Some("official-imagegen-bridge")
4299        );
4300        assert_eq!(
4301            state
4302                .get("original_auth_json")
4303                .and_then(|value| value.as_str()),
4304            Some(original_auth.as_str())
4305        );
4306        assert_eq!(
4307            state
4308                .get("patched_auth_json")
4309                .and_then(|value| value.as_str()),
4310            Some("{}")
4311        );
4312
4313        let status = codex_switch_status().expect("status should load");
4314        assert_eq!(
4315            status.patch_mode,
4316            Some(CodexPatchMode::OfficialImagegenBridge)
4317        );
4318        assert_eq!(status.requires_openai_auth, None);
4319        assert_eq!(status.supports_websockets, Some(false));
4320    }
4321
4322    #[test]
4323    fn codex_switch_on_official_imagegen_bridge_records_transport_option_and_patches_auth_json() {
4324        let env = setup_temp_env();
4325        write_helper_codex_config_with_env_auth(&env);
4326        let cfg_path = env.codex_home.join("config.toml");
4327        let auth_path = env.codex_home.join("auth.json");
4328        let state_path = env.codex_home.join("codex-helper-switch-state.json");
4329        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4330
4331        write_file(
4332            &cfg_path,
4333            r#"
4334model_provider = "openai"
4335
4336[model_providers.openai]
4337name = "openai"
4338base_url = "https://api.openai.com/v1"
4339"#
4340            .trim_start(),
4341        );
4342        write_file(&auth_path, &original_auth);
4343
4344        switch_on_with_options(
4345            3211,
4346            CodexPatchMode::OfficialImagegenBridge,
4347            CodexSwitchOptions {
4348                responses_websocket: true,
4349                compaction: CodexCompactionStrategy::Auto,
4350            },
4351        )
4352        .expect("switch_on official imagegen preset should patch config and auth with websocket transport");
4353
4354        let updated_cfg = read_file(&cfg_path);
4355        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
4356        assert!(updated_cfg.contains("name = \"OpenAI\""));
4357        assert!(updated_cfg.contains("supports_websockets = true"));
4358        assert!(!updated_cfg.contains("requires_openai_auth"));
4359
4360        let updated_auth: serde_json::Value =
4361            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
4362        assert!(
4363            updated_auth
4364                .as_object()
4365                .is_some_and(serde_json::Map::is_empty)
4366        );
4367
4368        let state_text = read_file(&state_path);
4369        let state: serde_json::Value = serde_json::from_str(&state_text).expect("valid state");
4370        assert_eq!(
4371            state.get("patch_mode").and_then(|value| value.as_str()),
4372            Some("official-imagegen-bridge")
4373        );
4374        assert_eq!(
4375            state
4376                .get("responses_websocket")
4377                .and_then(|value| value.as_bool()),
4378            Some(true)
4379        );
4380        assert_eq!(
4381            state
4382                .get("original_auth_json")
4383                .and_then(|value| value.as_str()),
4384            Some(original_auth.as_str())
4385        );
4386        assert_eq!(
4387            state
4388                .get("patched_auth_json")
4389                .and_then(|value| value.as_str()),
4390            Some("{}")
4391        );
4392
4393        let status = codex_switch_status().expect("status should load");
4394        assert_eq!(
4395            status.patch_mode,
4396            Some(CodexPatchMode::OfficialImagegenBridge)
4397        );
4398        assert_eq!(status.requires_openai_auth, None);
4399        assert_eq!(status.supports_websockets, Some(true));
4400    }
4401
4402    #[test]
4403    fn codex_switch_on_with_configured_preset_uses_helper_client_patch() {
4404        let env = setup_temp_env();
4405        write_helper_codex_config(
4406            &env,
4407            r#"
4408version = 5
4409
4410[codex.client_patch]
4411preset = "official-imagegen"
4412responses_websocket = true
4413hosted_image_generation = "disabled"
4414
4415[codex.providers.relay]
4416base_url = "https://relay.example/v1"
4417auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
4418
4419[codex.routing]
4420entry = "main"
4421
4422[codex.routing.routes.main]
4423strategy = "ordered-failover"
4424children = ["relay"]
4425"#,
4426        );
4427        let cfg_path = env.codex_home.join("config.toml");
4428        let auth_path = env.codex_home.join("auth.json");
4429        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4430
4431        write_file(
4432            &cfg_path,
4433            r#"
4434model_provider = "openai"
4435
4436[model_providers.openai]
4437name = "openai"
4438base_url = "https://api.openai.com/v1"
4439"#
4440            .trim_start(),
4441        );
4442        write_file(&auth_path, &original_auth);
4443
4444        switch_on_with_configured_preset(3211)
4445            .expect("configured official-imagegen preset should patch config and auth");
4446
4447        let updated_cfg = read_file(&cfg_path);
4448        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
4449        assert!(updated_cfg.contains("name = \"OpenAI\""));
4450        assert!(updated_cfg.contains("base_url = \"http://127.0.0.1:3211\""));
4451        assert!(updated_cfg.contains("supports_websockets = true"));
4452        assert!(updated_cfg.contains("image_generation = false"));
4453        assert!(!updated_cfg.contains("requires_openai_auth"));
4454
4455        let updated_auth: serde_json::Value =
4456            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
4457        assert!(
4458            updated_auth
4459                .as_object()
4460                .is_some_and(serde_json::Map::is_empty)
4461        );
4462
4463        let status = codex_switch_status().expect("status should load");
4464        assert_eq!(
4465            status.patch_mode,
4466            Some(CodexPatchMode::OfficialImagegenBridge)
4467        );
4468        assert_eq!(status.provider_name.as_deref(), Some("OpenAI"));
4469        assert_eq!(status.supports_websockets, Some(true));
4470    }
4471
4472    #[test]
4473    fn codex_switch_on_with_configured_compaction_local_overrides_official_provider_identity() {
4474        let env = setup_temp_env();
4475        write_helper_codex_config(
4476            &env,
4477            r#"
4478version = 5
4479
4480[codex.client_patch]
4481preset = "official-imagegen"
4482compaction = "local"
4483
4484[codex.providers.relay]
4485base_url = "https://relay.example/v1"
4486auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
4487
4488[codex.routing]
4489entry = "main"
4490
4491[codex.routing.routes.main]
4492strategy = "ordered-failover"
4493children = ["relay"]
4494"#,
4495        );
4496        let cfg_path = env.codex_home.join("config.toml");
4497        let auth_path = env.codex_home.join("auth.json");
4498        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4499
4500        write_file(
4501            &cfg_path,
4502            r#"
4503[features]
4504remote_compaction_v2 = true
4505
4506[model_providers.openai]
4507name = "openai"
4508base_url = "https://api.openai.com/v1"
4509"#
4510            .trim_start(),
4511        );
4512        write_file(&auth_path, &original_auth);
4513
4514        switch_on_with_configured_preset(3211)
4515            .expect("configured local compaction should patch config and auth");
4516
4517        let updated_cfg = read_file(&cfg_path);
4518        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
4519        assert!(updated_cfg.contains("name = \"codex-helper\""));
4520        assert!(updated_cfg.contains("remote_compaction_v2 = false"));
4521
4522        let updated_auth: serde_json::Value =
4523            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
4524        assert!(
4525            updated_auth
4526                .as_object()
4527                .is_some_and(serde_json::Map::is_empty)
4528        );
4529
4530        let status = codex_switch_status().expect("status should load");
4531        assert_eq!(
4532            status.patch_mode,
4533            Some(CodexPatchMode::OfficialImagegenBridge)
4534        );
4535        assert_eq!(status.provider_name.as_deref(), Some("codex-helper"));
4536        assert_eq!(status.compaction_strategy, CodexCompactionStrategy::Local);
4537    }
4538
4539    #[test]
4540    fn codex_switch_status_infers_official_relay_bridge_without_state() {
4541        let env = setup_temp_env();
4542        let cfg_path = env.codex_home.join("config.toml");
4543        write_file(
4544            &cfg_path,
4545            r#"
4546model_provider = "codex_proxy"
4547
4548[model_providers.codex_proxy]
4549name = "OpenAI"
4550base_url = "http://127.0.0.1:3211"
4551wire_api = "responses"
4552supports_websockets = false
4553"#
4554            .trim_start(),
4555        );
4556
4557        let status = codex_switch_status().expect("status should load");
4558
4559        assert!(status.enabled);
4560        assert_eq!(status.patch_mode, Some(CodexPatchMode::OfficialRelayBridge));
4561        assert_eq!(status.supports_websockets, Some(false));
4562        assert_eq!(status.requires_openai_auth, None);
4563        assert!(!status.has_switch_state);
4564    }
4565
4566    #[test]
4567    fn codex_switch_status_keeps_websocket_as_transport_for_official_relay_without_state() {
4568        let env = setup_temp_env();
4569        let cfg_path = env.codex_home.join("config.toml");
4570        write_file(
4571            &cfg_path,
4572            r#"
4573model_provider = "codex_proxy"
4574
4575[model_providers.codex_proxy]
4576name = "OpenAI"
4577base_url = "http://127.0.0.1:3211"
4578wire_api = "responses"
4579supports_websockets = true
4580"#
4581            .trim_start(),
4582        );
4583
4584        let status = codex_switch_status().expect("status should load");
4585
4586        assert!(status.enabled);
4587        assert_eq!(status.patch_mode, Some(CodexPatchMode::OfficialRelayBridge));
4588        assert_eq!(status.supports_websockets, Some(true));
4589        assert_eq!(status.requires_openai_auth, None);
4590        assert!(!status.has_switch_state);
4591    }
4592
4593    #[test]
4594    fn codex_switch_status_infers_remote_v2_compaction_without_state() {
4595        let env = setup_temp_env();
4596        let cfg_path = env.codex_home.join("config.toml");
4597        write_file(
4598            &cfg_path,
4599            r#"
4600model_provider = "codex_proxy"
4601
4602[features]
4603remote_compaction_v2 = true
4604
4605[model_providers.codex_proxy]
4606name = "OpenAI"
4607base_url = "http://127.0.0.1:3211"
4608wire_api = "responses"
4609supports_websockets = false
4610"#
4611            .trim_start(),
4612        );
4613
4614        let status = codex_switch_status().expect("status should load");
4615
4616        assert!(status.enabled);
4617        assert_eq!(
4618            status.compaction_strategy,
4619            CodexCompactionStrategy::RemoteV2
4620        );
4621        assert!(status.remote_compaction_v2_enabled);
4622        assert!(!status.has_switch_state);
4623    }
4624
4625    #[test]
4626    fn codex_switch_status_infers_official_imagegen_bridge_from_empty_auth_facade_without_state() {
4627        let env = setup_temp_env();
4628        let cfg_path = env.codex_home.join("config.toml");
4629        let auth_path = env.codex_home.join("auth.json");
4630        write_file(
4631            &cfg_path,
4632            r#"
4633model_provider = "codex_proxy"
4634
4635[model_providers.codex_proxy]
4636name = "OpenAI"
4637base_url = "http://127.0.0.1:3211"
4638wire_api = "responses"
4639supports_websockets = false
4640"#
4641            .trim_start(),
4642        );
4643        write_file(&auth_path, "{}");
4644
4645        let status = codex_switch_status().expect("status should load");
4646
4647        assert!(status.enabled);
4648        assert_eq!(
4649            status.patch_mode,
4650            Some(CodexPatchMode::OfficialImagegenBridge)
4651        );
4652        assert_eq!(status.supports_websockets, Some(false));
4653        assert_eq!(status.requires_openai_auth, None);
4654        assert!(!status.has_switch_state);
4655    }
4656
4657    #[test]
4658    fn codex_switch_status_keeps_websocket_as_transport_for_official_imagegen_without_state() {
4659        let env = setup_temp_env();
4660        let cfg_path = env.codex_home.join("config.toml");
4661        let auth_path = env.codex_home.join("auth.json");
4662        write_file(
4663            &cfg_path,
4664            r#"
4665model_provider = "codex_proxy"
4666
4667[model_providers.codex_proxy]
4668name = "OpenAI"
4669base_url = "http://127.0.0.1:3211"
4670wire_api = "responses"
4671supports_websockets = true
4672"#
4673            .trim_start(),
4674        );
4675        write_file(&auth_path, "{}");
4676
4677        let status = codex_switch_status().expect("status should load");
4678
4679        assert!(status.enabled);
4680        assert_eq!(
4681            status.patch_mode,
4682            Some(CodexPatchMode::OfficialImagegenBridge)
4683        );
4684        assert_eq!(status.supports_websockets, Some(true));
4685        assert_eq!(status.requires_openai_auth, None);
4686        assert!(!status.has_switch_state);
4687    }
4688
4689    #[test]
4690    fn codex_bridge_diagnostics_reports_ready_official_imagegen_bridge() {
4691        let env = setup_temp_env();
4692        write_helper_codex_config_with_env_auth(&env);
4693        let cfg_path = env.codex_home.join("config.toml");
4694        let auth_path = env.codex_home.join("auth.json");
4695        write_file(
4696            &cfg_path,
4697            r#"
4698model_provider = "codex_proxy"
4699
4700[model_providers.codex_proxy]
4701name = "OpenAI"
4702base_url = "http://127.0.0.1:3211"
4703wire_api = "responses"
4704supports_websockets = false
4705"#
4706            .trim_start(),
4707        );
4708        write_file(&auth_path, "{}");
4709
4710        let diagnostics = codex_bridge_diagnostics();
4711
4712        assert_eq!(
4713            diagnostics.patch_mode,
4714            Some(CodexPatchMode::OfficialImagegenBridge)
4715        );
4716        assert!(diagnostics.enabled);
4717        assert!(diagnostics.remote_compaction_v1_ready);
4718        assert!(diagnostics.imagegen_facade_ready);
4719        assert!(diagnostics.upstream_auth_ready);
4720        assert!(!diagnostics.remote_compaction_v2_enabled);
4721        assert_eq!(diagnostics.worst_status(), CodexBridgeDiagnosticStatus::Ok);
4722    }
4723
4724    #[test]
4725    fn codex_bridge_diagnostics_reports_ready_official_relay_with_responses_websocket() {
4726        let env = setup_temp_env();
4727        write_helper_codex_config_with_env_auth(&env);
4728        let cfg_path = env.codex_home.join("config.toml");
4729        write_file(
4730            &cfg_path,
4731            r#"
4732model_provider = "codex_proxy"
4733
4734[model_providers.codex_proxy]
4735name = "OpenAI"
4736base_url = "http://127.0.0.1:3211"
4737wire_api = "responses"
4738supports_websockets = true
4739"#
4740            .trim_start(),
4741        );
4742
4743        let diagnostics = codex_bridge_diagnostics();
4744
4745        assert_eq!(
4746            diagnostics.patch_mode,
4747            Some(CodexPatchMode::OfficialRelayBridge)
4748        );
4749        assert!(diagnostics.enabled);
4750        assert!(diagnostics.remote_compaction_v1_ready);
4751        assert!(!diagnostics.imagegen_facade_ready);
4752        assert!(diagnostics.upstream_auth_ready);
4753        let websocket_checks = diagnostics
4754            .checks
4755            .iter()
4756            .filter(|check| check.id == "codex_bridge.responses_websocket")
4757            .collect::<Vec<_>>();
4758        assert!(
4759            websocket_checks
4760                .iter()
4761                .any(|check| check.status == CodexBridgeDiagnosticStatus::Ok),
4762            "expected at least one ready websocket check: {websocket_checks:?}"
4763        );
4764        assert_eq!(
4765            diagnostics.worst_status(),
4766            CodexBridgeDiagnosticStatus::Info
4767        );
4768    }
4769
4770    #[test]
4771    fn codex_bridge_diagnostics_warns_when_remote_compaction_v2_is_enabled() {
4772        let env = setup_temp_env();
4773        write_helper_codex_config_with_env_auth(&env);
4774        let cfg_path = env.codex_home.join("config.toml");
4775        let auth_path = env.codex_home.join("auth.json");
4776        write_file(
4777            &cfg_path,
4778            r#"
4779model_provider = "codex_proxy"
4780
4781[features]
4782remote_compaction_v2 = true
4783
4784[model_providers.codex_proxy]
4785name = "OpenAI"
4786base_url = "http://127.0.0.1:3211"
4787wire_api = "responses"
4788supports_websockets = false
4789"#
4790            .trim_start(),
4791        );
4792        write_file(&auth_path, "{}");
4793
4794        let diagnostics = codex_bridge_diagnostics();
4795
4796        assert!(diagnostics.remote_compaction_v2_enabled);
4797        assert_eq!(
4798            diagnostics.worst_status(),
4799            CodexBridgeDiagnosticStatus::Warn
4800        );
4801        let v2 = diagnostics
4802            .checks
4803            .iter()
4804            .find(|check| check.id == "codex_bridge.remote_compaction_v2")
4805            .expect("v2 check");
4806        assert_eq!(v2.status, CodexBridgeDiagnosticStatus::Warn);
4807        assert!(v2.message.contains("remote_compaction_v2 is enabled"));
4808    }
4809
4810    #[test]
4811    fn codex_bridge_diagnostics_fails_imagegen_when_auth_facade_is_missing() {
4812        let env = setup_temp_env();
4813        write_helper_codex_config_with_env_auth(&env);
4814        let cfg_path = env.codex_home.join("config.toml");
4815        write_file(
4816            &cfg_path,
4817            r#"
4818model_provider = "codex_proxy"
4819
4820[model_providers.codex_proxy]
4821name = "OpenAI"
4822base_url = "http://127.0.0.1:3211"
4823wire_api = "responses"
4824supports_websockets = false
4825"#
4826            .trim_start(),
4827        );
4828        let state = CodexSwitchState {
4829            patch_mode: Some(CodexPatchMode::OfficialImagegenBridge),
4830            ..CodexSwitchState::from_codex_config_text("", true).expect("state")
4831        };
4832        write_codex_switch_state(&state).expect("write state");
4833
4834        let diagnostics = codex_bridge_diagnostics();
4835
4836        assert_eq!(
4837            diagnostics.patch_mode,
4838            Some(CodexPatchMode::OfficialImagegenBridge)
4839        );
4840        assert!(!diagnostics.imagegen_facade_ready);
4841        let imagegen = diagnostics
4842            .checks
4843            .iter()
4844            .find(|check| check.id == "codex_bridge.imagegen_facade")
4845            .expect("imagegen check");
4846        assert_eq!(imagegen.status, CodexBridgeDiagnosticStatus::Fail);
4847    }
4848
4849    #[test]
4850    fn codex_bridge_runtime_auth_snapshot_reports_missing_env_names() {
4851        let env = setup_temp_env();
4852        write_helper_codex_config(
4853            &env,
4854            r#"
4855version = 5
4856
4857[codex.providers.relay]
4858base_url = "https://relay.example/v1"
4859auth_token_env = "CODEX_HELPER_MISSING_IMAGEGEN_KEY"
4860
4861[codex.routing]
4862entry = "main"
4863
4864[codex.routing.routes.main]
4865strategy = "ordered-failover"
4866children = ["relay"]
4867"#,
4868        );
4869
4870        let snapshot = codex_bridge_runtime_auth_snapshot().expect("snapshot");
4871
4872        assert_eq!(snapshot.routable_upstreams, 1);
4873        assert_eq!(snapshot.authed_upstreams, 0);
4874        assert_eq!(
4875            snapshot.missing_env,
4876            vec!["CODEX_HELPER_MISSING_IMAGEGEN_KEY".to_string()]
4877        );
4878    }
4879
4880    #[test]
4881    fn codex_switch_default_restores_imagegen_bridge_auth_json() {
4882        let env = setup_temp_env();
4883        write_helper_codex_config_with_env_auth(&env);
4884        let cfg_path = env.codex_home.join("config.toml");
4885        let auth_path = env.codex_home.join("auth.json");
4886        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4887
4888        write_file(
4889            &cfg_path,
4890            r#"
4891model_provider = "openai"
4892
4893[model_providers.openai]
4894name = "openai"
4895base_url = "https://api.openai.com/v1"
4896"#
4897            .trim_start(),
4898        );
4899        write_file(&auth_path, &original_auth);
4900
4901        switch_on_with_mode(3211, CodexPatchMode::ImagegenBridge)
4902            .expect("switch_on imagegen bridge should patch auth");
4903        assert_ne!(read_file(&auth_path), original_auth);
4904
4905        switch_on_with_mode(3211, CodexPatchMode::Default)
4906            .expect("switching to default should restore auth");
4907
4908        assert_eq!(read_file(&auth_path), original_auth);
4909        let status = codex_switch_status().expect("status should load");
4910        assert_eq!(status.patch_mode, Some(CodexPatchMode::Default));
4911    }
4912
4913    #[test]
4914    fn codex_switch_default_restores_official_imagegen_bridge_auth_json() {
4915        let env = setup_temp_env();
4916        write_helper_codex_config_with_env_auth(&env);
4917        let cfg_path = env.codex_home.join("config.toml");
4918        let auth_path = env.codex_home.join("auth.json");
4919        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4920
4921        write_file(
4922            &cfg_path,
4923            r#"
4924model_provider = "openai"
4925
4926[model_providers.openai]
4927name = "openai"
4928base_url = "https://api.openai.com/v1"
4929"#
4930            .trim_start(),
4931        );
4932        write_file(&auth_path, &original_auth);
4933
4934        switch_on_with_mode(3211, CodexPatchMode::OfficialImagegenBridge)
4935            .expect("switch_on official imagegen preset should patch auth");
4936        assert_ne!(read_file(&auth_path), original_auth);
4937
4938        switch_on_with_mode(3211, CodexPatchMode::Default)
4939            .expect("switching to default should restore auth");
4940
4941        assert_eq!(read_file(&auth_path), original_auth);
4942        let status = codex_switch_status().expect("status should load");
4943        assert_eq!(status.patch_mode, Some(CodexPatchMode::Default));
4944    }
4945
4946    #[test]
4947    fn codex_switch_default_restores_official_imagegen_bridge_auth_json_with_responses_websocket() {
4948        let env = setup_temp_env();
4949        write_helper_codex_config_with_env_auth(&env);
4950        let cfg_path = env.codex_home.join("config.toml");
4951        let auth_path = env.codex_home.join("auth.json");
4952        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4953
4954        write_file(
4955            &cfg_path,
4956            r#"
4957model_provider = "openai"
4958
4959[model_providers.openai]
4960name = "openai"
4961base_url = "https://api.openai.com/v1"
4962"#
4963            .trim_start(),
4964        );
4965        write_file(&auth_path, &original_auth);
4966
4967        switch_on_with_options(
4968            3211,
4969            CodexPatchMode::OfficialImagegenBridge,
4970            CodexSwitchOptions {
4971                responses_websocket: true,
4972                compaction: CodexCompactionStrategy::Auto,
4973            },
4974        )
4975        .expect("switch_on official imagegen preset should patch auth with websocket transport");
4976        assert_ne!(read_file(&auth_path), original_auth);
4977
4978        switch_on_with_mode(3211, CodexPatchMode::Default)
4979            .expect("switching to default should restore auth");
4980
4981        assert_eq!(read_file(&auth_path), original_auth);
4982        let status = codex_switch_status().expect("status should load");
4983        assert_eq!(status.patch_mode, Some(CodexPatchMode::Default));
4984    }
4985
4986    #[test]
4987    fn codex_switch_off_restores_imagegen_bridge_auth_json() {
4988        let env = setup_temp_env();
4989        write_helper_codex_config_with_env_auth(&env);
4990        let cfg_path = env.codex_home.join("config.toml");
4991        let auth_path = env.codex_home.join("auth.json");
4992        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4993
4994        write_file(
4995            &cfg_path,
4996            r#"
4997model_provider = "openai"
4998
4999[model_providers.openai]
5000name = "openai"
5001base_url = "https://api.openai.com/v1"
5002"#
5003            .trim_start(),
5004        );
5005        write_file(&auth_path, &original_auth);
5006
5007        switch_on_with_mode(3211, CodexPatchMode::ImagegenBridge)
5008            .expect("switch_on imagegen bridge should patch auth");
5009        switch_off().expect("switch_off should restore auth");
5010
5011        assert_eq!(read_file(&auth_path), original_auth);
5012        assert!(read_file(&cfg_path).contains("model_provider = \"openai\""));
5013    }
5014
5015    #[test]
5016    fn codex_switch_off_does_not_restore_auth_if_user_changed_it() {
5017        let env = setup_temp_env();
5018        write_helper_codex_config_with_env_auth(&env);
5019        let cfg_path = env.codex_home.join("config.toml");
5020        let auth_path = env.codex_home.join("auth.json");
5021        let user_auth = r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-user"}"#;
5022
5023        write_file(
5024            &cfg_path,
5025            r#"
5026model_provider = "openai"
5027
5028[model_providers.openai]
5029name = "openai"
5030base_url = "https://api.openai.com/v1"
5031"#
5032            .trim_start(),
5033        );
5034        write_file(
5035            &auth_path,
5036            &chatgpt_auth_json("user@example.com", "acct_1", "plus"),
5037        );
5038
5039        switch_on_with_mode(3211, CodexPatchMode::ImagegenBridge)
5040            .expect("switch_on imagegen bridge should patch auth");
5041        write_file(&auth_path, user_auth);
5042        switch_off().expect("switch_off should leave user auth alone");
5043
5044        assert_eq!(read_file(&auth_path), user_auth);
5045    }
5046
5047    #[test]
5048    fn codex_switch_imagegen_to_chatgpt_bridge_uses_original_auth_json() {
5049        let env = setup_temp_env();
5050        write_helper_codex_config_with_env_auth(&env);
5051        let cfg_path = env.codex_home.join("config.toml");
5052        let auth_path = env.codex_home.join("auth.json");
5053        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
5054
5055        write_file(
5056            &cfg_path,
5057            r#"
5058model_provider = "openai"
5059
5060[model_providers.openai]
5061name = "openai"
5062base_url = "https://api.openai.com/v1"
5063"#
5064            .trim_start(),
5065        );
5066        write_file(&auth_path, &original_auth);
5067
5068        switch_on_with_mode(3211, CodexPatchMode::ImagegenBridge)
5069            .expect("switch_on imagegen bridge should patch auth");
5070        switch_on_with_mode(3211, CodexPatchMode::ChatGptBridge)
5071            .expect("switching to chatgpt bridge should use original auth snapshot");
5072
5073        let updated_auth: serde_json::Value =
5074            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
5075        assert_eq!(
5076            updated_auth
5077                .get("tokens")
5078                .and_then(|value| value.get("account_id"))
5079                .and_then(|value| value.as_str()),
5080            Some("acct_1")
5081        );
5082        assert!(
5083            updated_auth
5084                .get("OPENAI_API_KEY")
5085                .is_some_and(|value| value.is_null())
5086        );
5087    }
5088
5089    #[test]
5090    fn codex_switch_official_imagegen_to_chatgpt_bridge_uses_original_auth_json() {
5091        let env = setup_temp_env();
5092        write_helper_codex_config_with_env_auth(&env);
5093        let cfg_path = env.codex_home.join("config.toml");
5094        let auth_path = env.codex_home.join("auth.json");
5095        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
5096
5097        write_file(
5098            &cfg_path,
5099            r#"
5100model_provider = "openai"
5101
5102[model_providers.openai]
5103name = "openai"
5104base_url = "https://api.openai.com/v1"
5105"#
5106            .trim_start(),
5107        );
5108        write_file(&auth_path, &original_auth);
5109
5110        switch_on_with_mode(3211, CodexPatchMode::OfficialImagegenBridge)
5111            .expect("switch_on official imagegen preset should patch auth");
5112        switch_on_with_mode(3211, CodexPatchMode::ChatGptBridge)
5113            .expect("switching to chatgpt bridge should use original auth snapshot");
5114
5115        let updated_cfg = read_file(&cfg_path);
5116        assert!(updated_cfg.contains("name = \"codex-helper\""));
5117        assert!(updated_cfg.contains("requires_openai_auth = true"));
5118
5119        let updated_auth: serde_json::Value =
5120            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
5121        assert_eq!(
5122            updated_auth
5123                .get("tokens")
5124                .and_then(|value| value.get("account_id"))
5125                .and_then(|value| value.as_str()),
5126            Some("acct_1")
5127        );
5128        assert!(
5129            updated_auth
5130                .get("OPENAI_API_KEY")
5131                .is_some_and(|value| value.is_null())
5132        );
5133    }
5134
5135    #[test]
5136    fn codex_switch_on_chatgpt_bridge_does_not_rewrite_already_patched_auth_json() {
5137        let env = setup_temp_env();
5138        let cfg_path = env.codex_home.join("config.toml");
5139        let auth_path = env.codex_home.join("auth.json");
5140
5141        write_file(
5142            &cfg_path,
5143            r#"
5144model_provider = "openai"
5145
5146[model_providers.openai]
5147name = "openai"
5148base_url = "https://api.openai.com/v1"
5149"#
5150            .trim_start(),
5151        );
5152        write_file(
5153            &auth_path,
5154            &chatgpt_bridge_auth_json_text(&chatgpt_auth_json(
5155                "user@example.com",
5156                "acct_1",
5157                "plus",
5158            ))
5159            .expect("pre-patch auth fixture"),
5160        );
5161        let before = std::fs::metadata(&auth_path)
5162            .expect("auth metadata")
5163            .modified()
5164            .expect("auth modified time");
5165
5166        switch_on_with_mode(3211, CodexPatchMode::ChatGptBridge)
5167            .expect("switch_on bridge should patch config without rewriting already-patched auth");
5168
5169        let after = std::fs::metadata(&auth_path)
5170            .expect("auth metadata")
5171            .modified()
5172            .expect("auth modified time");
5173        assert_eq!(before, after);
5174    }
5175
5176    #[test]
5177    fn codex_switch_on_chatgpt_bridge_refuses_incomplete_auth_without_writing_config() {
5178        let env = setup_temp_env();
5179        let cfg_path = env.codex_home.join("config.toml");
5180        let auth_path = env.codex_home.join("auth.json");
5181        let state_path = env.codex_home.join("codex-helper-switch-state.json");
5182        let original_config = r#"
5183model_provider = "openai"
5184
5185[model_providers.openai]
5186name = "openai"
5187base_url = "https://api.openai.com/v1"
5188"#
5189        .trim_start();
5190        let original_auth = r#"{"auth_mode":"chatgpt","OPENAI_API_KEY":null}"#;
5191
5192        write_file(&cfg_path, original_config);
5193        write_file(&auth_path, original_auth);
5194
5195        let err = switch_on_with_mode(3211, CodexPatchMode::ChatGptBridge)
5196            .expect_err("incomplete ChatGPT auth should be rejected before writing config");
5197
5198        assert!(err.to_string().contains("complete ChatGPT login state"));
5199        assert_eq!(read_file(&cfg_path), original_config);
5200        assert_eq!(read_file(&auth_path), original_auth);
5201        assert!(
5202            !state_path.exists(),
5203            "failed bridge switch must not create switch state"
5204        );
5205    }
5206
5207    #[test]
5208    fn codex_switch_on_refuses_local_proxy_without_switch_state() {
5209        let env = setup_temp_env();
5210        let cfg_path = env.codex_home.join("config.toml");
5211        let state_path = env.codex_home.join("codex-helper-switch-state.json");
5212
5213        write_file(
5214            &cfg_path,
5215            r#"
5216model_provider = "codex_proxy"
5217
5218[model_providers.codex_proxy]
5219name = "codex-helper"
5220base_url = "http://127.0.0.1:3211"
5221"#
5222            .trim_start(),
5223        );
5224
5225        let err = switch_on(3211).expect_err("switch_on should not snapshot a local proxy");
5226        assert!(err.to_string().contains("no switch state was found"));
5227        assert!(
5228            !state_path.exists(),
5229            "switch_on must not create state from an already-patched local proxy"
5230        );
5231    }
5232
5233    #[test]
5234    fn codex_config_text_for_import_hides_proxy_created_from_absent_config() {
5235        let env = setup_temp_env();
5236        let cfg_path = env.codex_home.join("config.toml");
5237
5238        switch_on(3211).expect("switch_on should create config");
5239        assert!(cfg_path.exists());
5240
5241        let import_text = codex_config_text_for_import()
5242            .expect("read import view")
5243            .expect("config exists");
5244        assert!(
5245            import_text.trim().is_empty(),
5246            "import view should not expose helper proxy as a real upstream"
5247        );
5248    }
5249
5250    #[test]
5251    fn codex_switch_off_clears_switch_state_and_refreshes_next_snapshot() {
5252        let env = setup_temp_env();
5253        let cfg_path = env.codex_home.join("config.toml");
5254        let state_path = env.codex_home.join("codex-helper-switch-state.json");
5255
5256        let original = r#"
5257model_provider = "openai"
5258
5259[model_providers.openai]
5260name = "openai"
5261base_url = "https://api.openai.com/v1"
5262"#;
5263        let updated = r#"
5264model_provider = "packycode"
5265
5266[model_providers.packycode]
5267name = "packycode"
5268base_url = "https://codex-api.packycode.com/v1"
5269"#;
5270
5271        write_file(&cfg_path, original.trim_start());
5272        switch_on(3211).expect("first switch_on should succeed");
5273        assert!(
5274            state_path.exists(),
5275            "switch state should exist while patched"
5276        );
5277        let state_text = read_file(&state_path);
5278        assert!(state_text.contains("\"original_model_provider\": \"openai\""));
5279        assert!(
5280            !state_text.contains("api.openai.com"),
5281            "switch state should not store the full Codex config"
5282        );
5283
5284        switch_off().expect("first switch_off should succeed");
5285        assert_eq!(read_file(&cfg_path), original.trim_start());
5286        assert!(
5287            !state_path.exists(),
5288            "switch state should be removed after patch-off to avoid stale snapshots"
5289        );
5290
5291        write_file(&cfg_path, updated.trim_start());
5292        switch_on(3211).expect("second switch_on should succeed");
5293        let state_text = read_file(&state_path);
5294        assert!(state_text.contains("\"original_model_provider\": \"packycode\""));
5295
5296        switch_off().expect("second switch_off should succeed");
5297        assert_eq!(read_file(&cfg_path), updated.trim_start());
5298        assert!(
5299            !state_path.exists(),
5300            "switch state should be cleaned up after the second patch-off as well"
5301        );
5302    }
5303
5304    #[test]
5305    fn codex_switch_off_preserves_codex_runtime_config_edits() {
5306        let env = setup_temp_env();
5307        let cfg_path = env.codex_home.join("config.toml");
5308        let state_path = env.codex_home.join("codex-helper-switch-state.json");
5309
5310        let original = r#"
5311model_provider = "openai"
5312
5313[model_providers.openai]
5314name = "openai"
5315base_url = "https://api.openai.com/v1"
5316"#;
5317
5318        write_file(&cfg_path, original.trim_start());
5319        switch_on(3211).expect("switch_on should succeed");
5320
5321        let mut during_run = read_file(&cfg_path);
5322        during_run.push_str(
5323            r#"
5324[projects."D:\\Projects\\rust\\codex-helper"]
5325trust_level = "trusted"
5326"#,
5327        );
5328        write_file(&cfg_path, &during_run);
5329
5330        switch_off().expect("switch_off should patch rather than restore whole file");
5331
5332        let updated = read_file(&cfg_path);
5333        assert!(updated.contains("model_provider = \"openai\""));
5334        assert!(updated.contains("[model_providers.openai]"));
5335        assert!(!updated.contains("[model_providers.codex_proxy]"));
5336        assert!(updated.contains("[projects."));
5337        assert!(updated.contains("trust_level = \"trusted\""));
5338        assert!(
5339            !state_path.exists(),
5340            "switch state should be removed after successful patch-off"
5341        );
5342    }
5343
5344    #[test]
5345    fn codex_switch_off_keeps_user_provider_change_made_during_run() {
5346        let env = setup_temp_env();
5347        let cfg_path = env.codex_home.join("config.toml");
5348
5349        let original = r#"
5350model_provider = "openai"
5351
5352[model_providers.openai]
5353name = "openai"
5354base_url = "https://api.openai.com/v1"
5355"#;
5356        let user_changed = r#"
5357model_provider = "packycode"
5358
5359[model_providers.openai]
5360name = "openai"
5361base_url = "https://api.openai.com/v1"
5362
5363[model_providers.codex_proxy]
5364name = "codex-helper"
5365base_url = "http://127.0.0.1:3211"
5366wire_api = "responses"
5367request_max_retries = 0
5368
5369[model_providers.packycode]
5370name = "packycode"
5371base_url = "https://codex-api.packycode.com/v1"
5372"#;
5373
5374        write_file(&cfg_path, original.trim_start());
5375        switch_on(3211).expect("switch_on should succeed");
5376        write_file(&cfg_path, user_changed.trim_start());
5377
5378        switch_off().expect("switch_off should not undo user's model_provider change");
5379
5380        let updated = read_file(&cfg_path);
5381        assert!(updated.contains("model_provider = \"packycode\""));
5382        assert!(updated.contains("[model_providers.packycode]"));
5383        assert!(!updated.contains("[model_providers.codex_proxy]"));
5384    }
5385
5386    #[test]
5387    fn codex_switch_off_preserves_new_config_when_original_was_absent() {
5388        let env = setup_temp_env();
5389        let cfg_path = env.codex_home.join("config.toml");
5390
5391        switch_on(3211).expect("switch_on should create config");
5392        let mut during_run = read_file(&cfg_path);
5393        during_run.push_str(
5394            r#"
5395[projects."D:\\Projects\\rust\\codex-helper"]
5396trust_level = "trusted"
5397"#,
5398        );
5399        write_file(&cfg_path, &during_run);
5400
5401        switch_off().expect("switch_off should remove only local proxy fields");
5402
5403        let updated = read_file(&cfg_path);
5404        assert!(!updated.contains("model_provider = \"codex_proxy\""));
5405        assert!(!updated.contains("[model_providers.codex_proxy]"));
5406        assert!(updated.contains("[projects."));
5407        assert!(updated.contains("trust_level = \"trusted\""));
5408    }
5409
5410    #[test]
5411    fn codex_switch_off_removes_empty_config_created_by_switch_on() {
5412        let env = setup_temp_env();
5413        let cfg_path = env.codex_home.join("config.toml");
5414
5415        switch_on(3211).expect("switch_on should create config");
5416        assert!(cfg_path.exists());
5417
5418        switch_off().expect("switch_off should restore absent config state");
5419
5420        assert!(
5421            !cfg_path.exists(),
5422            "config created only for the local proxy should be removed"
5423        );
5424    }
5425
5426    #[test]
5427    fn claude_switch_off_clears_backup_and_refreshes_next_snapshot() {
5428        let env = setup_temp_env();
5429        let settings_path = env.claude_home.join("settings.json");
5430        let backup_path = env.claude_home.join("settings.json.codex-helper-backup");
5431
5432        let original = r#"{
5433  "env": {
5434    "ANTHROPIC_BASE_URL": "https://api.anthropic.com/v1",
5435    "ANTHROPIC_API_KEY": "sk-ant-1"
5436  }
5437}"#;
5438        let updated = r#"{
5439  "env": {
5440    "ANTHROPIC_BASE_URL": "https://anthropic-proxy.example/v1",
5441    "ANTHROPIC_API_KEY": "sk-ant-2"
5442  }
5443}"#;
5444
5445        write_file(&settings_path, original);
5446        claude_switch_on(3211).expect("first claude_switch_on should succeed");
5447        assert!(
5448            backup_path.exists(),
5449            "backup should exist while switched on"
5450        );
5451
5452        claude_switch_off().expect("first claude_switch_off should succeed");
5453        assert_eq!(read_file(&settings_path), original);
5454        assert!(
5455            !backup_path.exists(),
5456            "backup should be removed after Claude restore to avoid stale snapshots"
5457        );
5458
5459        write_file(&settings_path, updated);
5460        claude_switch_on(3211).expect("second claude_switch_on should succeed");
5461        assert_eq!(read_file(&backup_path), updated);
5462
5463        claude_switch_off().expect("second claude_switch_off should succeed");
5464        assert_eq!(read_file(&settings_path), updated);
5465        assert!(
5466            !backup_path.exists(),
5467            "backup should be cleaned up after the second Claude restore as well"
5468        );
5469    }
5470}
5471
5472/// Warn before replacing an existing local Claude proxy patch.
5473pub fn guard_claude_settings_before_switch_on_interactive() -> Result<()> {
5474    use std::io::{self, Write};
5475
5476    let settings_path = claude_settings_path();
5477    if !settings_path.exists() {
5478        return Ok(());
5479    }
5480    let backup_path = claude_settings_backup_path(&settings_path);
5481
5482    let text = read_settings_text(&settings_path)?;
5483    if text.trim().is_empty() {
5484        return Ok(());
5485    }
5486
5487    let value: serde_json::Value = match serde_json::from_str(&text) {
5488        Ok(value) => value,
5489        Err(_) => return Ok(()),
5490    };
5491    let obj = match value.as_object() {
5492        Some(obj) => obj,
5493        None => return Ok(()),
5494    };
5495    let env_obj = match obj.get("env").and_then(|value| value.as_object()) {
5496        Some(env_obj) => env_obj,
5497        None => return Ok(()),
5498    };
5499
5500    let base_url = env_obj
5501        .get("ANTHROPIC_BASE_URL")
5502        .and_then(|value| value.as_str())
5503        .unwrap_or_default();
5504
5505    let is_local = base_url.contains("127.0.0.1") || base_url.contains("localhost");
5506    if !is_local {
5507        return Ok(());
5508    }
5509
5510    if !backup_path.exists() {
5511        eprintln!(
5512            "Warning: Claude settings {:?} points ANTHROPIC_BASE_URL to a local address ({base_url}), but no backup file {:?} was found; please inspect this config file manually if this is unexpected.",
5513            settings_path, backup_path
5514        );
5515        return Ok(());
5516    }
5517
5518    let is_tty = atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout);
5519    if !is_tty {
5520        eprintln!(
5521            "Notice: Claude settings {:?} already points to the local proxy ({base_url}), and backup {:?} exists; run `codex-helper switch off --claude` if you want to restore the original config.",
5522            settings_path, backup_path
5523        );
5524        return Ok(());
5525    }
5526
5527    eprintln!(
5528        "Claude settings {:?} already points ANTHROPIC_BASE_URL to the local proxy ({base_url}), and backup {:?} exists.\nThis usually means the previous run did not switch off cleanly.\nRestore the original Claude settings now? [Y/n] ",
5529        settings_path, backup_path
5530    );
5531    eprint!("> ");
5532    io::stdout().flush().ok();
5533
5534    let mut input = String::new();
5535    if let Err(err) = io::stdin().read_line(&mut input) {
5536        eprintln!("Failed to read input: {err}");
5537        return Ok(());
5538    }
5539    let answer = input.trim();
5540    let yes =
5541        answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes");
5542
5543    if yes {
5544        if let Err(err) = claude_switch_off() {
5545            eprintln!("Failed to restore Claude settings: {err}");
5546        } else {
5547            eprintln!("Restored Claude settings from backup.");
5548        }
5549    } else {
5550        eprintln!("Keeping current Claude settings unchanged.");
5551    }
5552
5553    Ok(())
5554}