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