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; helper will detect compaction_trigger requests, but relay compatibility is still less stable than v1 /responses/compact.".to_string(),
1307                    Some(
1308                        "Only keep [features].remote_compaction_v2 = true when your relay explicitly supports compaction_trigger requests 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 using the preset configured in
1905/// `~/.codex-helper/config.toml`.
1906pub fn switch_on_with_configured_preset(port: u16) -> Result<()> {
1907    let client_patch = crate::config::codex_client_patch_config_from_config_file()
1908        .context("read codex.client_patch from codex-helper config before switch on")?;
1909    switch_on_with_options(port, client_patch.preset, client_patch.options)
1910}
1911
1912/// Switch Codex to use the local codex-helper model provider with an explicit client preset.
1913pub fn switch_on_with_mode(port: u16, mode: CodexPatchMode) -> Result<()> {
1914    switch_on_with_options(port, mode, CodexSwitchOptions::default())
1915}
1916
1917/// Switch Codex to use the local codex-helper model provider with an explicit client preset and
1918/// transport options.
1919pub fn switch_on_with_options(
1920    port: u16,
1921    mode: CodexPatchMode,
1922    options: CodexSwitchOptions,
1923) -> Result<()> {
1924    let plan = CodexPatchPlan::for_switch_on(mode, options)?;
1925    if plan.requires_bridge_runtime_ready() {
1926        ensure_bridge_runtime_ready(plan.mode())?;
1927    }
1928
1929    let cfg_path = codex_config_path();
1930    let state_path = codex_switch_state_path();
1931    let text = read_config_text(&cfg_path)?;
1932    if !state_path.exists() && codex_text_points_to_local_helper(&text)? {
1933        return Err(anyhow!(
1934            "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.",
1935            state_path
1936        ));
1937    }
1938    let mut state = if state_path.exists() {
1939        read_codex_switch_state()?.ok_or_else(|| {
1940            anyhow!(
1941                "missing Codex switch state at {:?}",
1942                codex_switch_state_path()
1943            )
1944        })?
1945    } else {
1946        CodexSwitchState::from_codex_config_text(&text, !cfg_path.exists())?
1947    };
1948    state.patch_mode = Some(plan.mode());
1949    state.responses_websocket = plan.options().responses_websocket;
1950
1951    let auth_edit = auth_edit_for_switch_on_plan(plan, &mut state)?;
1952    let new_text = switch_on_codex_toml_with_plan(&text, port, plan)?;
1953    apply_switch_on_effects(plan, &cfg_path, &new_text, &state, auth_edit)?;
1954    Ok(())
1955}
1956
1957/// Undo the local Codex proxy patch while preserving config edits made during the run.
1958pub fn switch_off() -> Result<()> {
1959    let cfg_path = codex_config_path();
1960    let state_path = codex_switch_state_path();
1961    if state_path.exists() {
1962        let mut state = read_codex_switch_state()?.ok_or_else(|| {
1963            anyhow!(
1964                "missing Codex switch state at {:?}",
1965                codex_switch_state_path()
1966            )
1967        })?;
1968        let auth_edit = auth_restore_edit_from_state(&mut state)?;
1969        if !cfg_path.exists() {
1970            apply_codex_auth_edit(auth_edit)?;
1971            fs::remove_file(&state_path)
1972                .with_context(|| format!("remove stale switch state {:?}", state_path))?;
1973            return Ok(());
1974        }
1975        let current_text = read_config_text(&cfg_path)?;
1976        match switch_off_codex_toml(&current_text, &state)? {
1977            CodexSwitchOffEdit::RemoveFile => {
1978                if cfg_path.exists() {
1979                    fs::remove_file(&cfg_path)
1980                        .with_context(|| format!("remove {:?} (restore absent)", cfg_path))?;
1981                }
1982            }
1983            CodexSwitchOffEdit::Write(text) => {
1984                atomic_write(&cfg_path, &text)
1985                    .with_context(|| format!("patch {:?} to disable local proxy", cfg_path))?;
1986            }
1987        }
1988        apply_codex_auth_edit(auth_edit)?;
1989        fs::remove_file(&state_path)
1990            .with_context(|| format!("remove stale switch state {:?}", state_path))?;
1991    }
1992    Ok(())
1993}
1994
1995#[derive(Debug, Clone)]
1996pub struct CodexRemoteControlStatus {
1997    pub config_path: PathBuf,
1998    pub remote_connections_enabled: bool,
1999    pub remote_control_config_present: bool,
2000    pub db_path: PathBuf,
2001    pub db_exists: bool,
2002    pub db_table_exists: bool,
2003    pub db_enabled: Option<bool>,
2004    pub db_updated_at: Option<i64>,
2005}
2006
2007#[derive(Debug, Clone)]
2008pub struct CodexRemoteControlEnablement {
2009    pub status: CodexRemoteControlStatus,
2010    pub backup_path: PathBuf,
2011}
2012
2013pub fn codex_remote_control_enable() -> Result<CodexRemoteControlEnablement> {
2014    let cfg_path = codex_config_path();
2015    let config_text = read_config_text(&cfg_path)?;
2016    let updated_config = ensure_codex_remote_connections_feature_in_toml(&config_text)?;
2017    let db_path = codex_app_db_path();
2018
2019    validate_codex_remote_control_feature_enablement_schema(&db_path)?;
2020    let backup_path = backup_codex_app_db(&db_path)?;
2021
2022    if updated_config != config_text {
2023        atomic_write(&cfg_path, &updated_config)
2024            .with_context(|| format!("patch {:?} for Codex remote connections", cfg_path))?;
2025    }
2026
2027    upsert_codex_remote_control_feature_enablement(&db_path)?;
2028    let status = codex_remote_control_status()?;
2029
2030    Ok(CodexRemoteControlEnablement {
2031        status,
2032        backup_path,
2033    })
2034}
2035
2036pub fn codex_remote_control_status() -> Result<CodexRemoteControlStatus> {
2037    let config_path = codex_config_path();
2038    let config_text = read_config_text(&config_path)?;
2039    let remote_connections_enabled =
2040        codex_remote_connections_feature_enabled_from_toml(&config_text)?;
2041    let remote_control_config_present = codex_remote_control_feature_present_in_toml(&config_text)?;
2042
2043    let db_path = codex_app_db_path();
2044    let db_exists = db_path.exists();
2045    let db_status = if db_exists {
2046        read_codex_remote_control_db_status(&db_path)?
2047    } else {
2048        CodexRemoteControlDbStatus {
2049            table_exists: false,
2050            enabled: None,
2051            updated_at: None,
2052        }
2053    };
2054
2055    Ok(CodexRemoteControlStatus {
2056        config_path,
2057        remote_connections_enabled,
2058        remote_control_config_present,
2059        db_path,
2060        db_exists,
2061        db_table_exists: db_status.table_exists,
2062        db_enabled: db_status.enabled,
2063        db_updated_at: db_status.updated_at,
2064    })
2065}
2066
2067fn backup_codex_app_db(db_path: &Path) -> Result<PathBuf> {
2068    if !db_path.exists() {
2069        return Err(anyhow!(
2070            "Codex App SQLite database not found at {:?}; open Codex App once so it creates the local database, then retry",
2071            db_path
2072        ));
2073    }
2074
2075    let timestamp = timestamp_for_backup_filename();
2076    let file_name = db_path
2077        .file_name()
2078        .and_then(|name| name.to_str())
2079        .unwrap_or("codex-dev.db");
2080    let backup_path = db_path.with_file_name(format!("{file_name}.{timestamp}.bak"));
2081    fs::copy(db_path, &backup_path)
2082        .with_context(|| format!("backup {:?} -> {:?}", db_path, backup_path))?;
2083    Ok(backup_path)
2084}
2085
2086fn validate_codex_remote_control_feature_enablement_schema(db_path: &Path) -> Result<()> {
2087    if !db_path.exists() {
2088        return Err(anyhow!(
2089            "Codex App SQLite database not found at {:?}; open Codex App once so it creates the local database, then retry",
2090            db_path
2091        ));
2092    }
2093
2094    let conn = rusqlite::Connection::open(db_path)
2095        .with_context(|| format!("open Codex App SQLite database {:?}", db_path))?;
2096    let columns = local_app_server_feature_enablement_columns(&conn)?;
2097    if columns.is_empty() {
2098        return Err(anyhow!(
2099            "SQLite table local_app_server_feature_enablement not found in {:?}; this Codex App build may use a different desktop state schema",
2100            db_path
2101        ));
2102    }
2103    ensure_required_enablement_columns(&columns)
2104}
2105
2106fn timestamp_for_backup_filename() -> String {
2107    use std::time::{SystemTime, UNIX_EPOCH};
2108
2109    let millis = SystemTime::now()
2110        .duration_since(UNIX_EPOCH)
2111        .map(|duration| duration.as_millis())
2112        .unwrap_or(0);
2113    millis.to_string()
2114}
2115
2116fn current_unix_millis_i64() -> i64 {
2117    use std::time::{SystemTime, UNIX_EPOCH};
2118
2119    SystemTime::now()
2120        .duration_since(UNIX_EPOCH)
2121        .map(|duration| duration.as_millis().min(i64::MAX as u128) as i64)
2122        .unwrap_or(0)
2123}
2124
2125fn upsert_codex_remote_control_feature_enablement(db_path: &Path) -> Result<()> {
2126    let conn = rusqlite::Connection::open(db_path)
2127        .with_context(|| format!("open Codex App SQLite database {:?}", db_path))?;
2128    let columns = local_app_server_feature_enablement_columns(&conn)?;
2129    if columns.is_empty() {
2130        return Err(anyhow!(
2131            "SQLite table local_app_server_feature_enablement not found in {:?}; this Codex App build may use a different desktop state schema",
2132            db_path
2133        ));
2134    }
2135    ensure_required_enablement_columns(&columns)?;
2136
2137    let updated_at = current_unix_millis_i64();
2138    let column_names = columns
2139        .iter()
2140        .map(|column| column.name.as_str())
2141        .collect::<Vec<_>>();
2142    let has_created_at = column_names.contains(&"created_at");
2143
2144    let updated_rows = conn
2145        .execute(
2146            "UPDATE local_app_server_feature_enablement \
2147             SET enabled = ?1, updated_at = ?2 \
2148             WHERE feature_name = ?3",
2149            rusqlite::params![1_i64, updated_at, "remote_control"],
2150        )
2151        .with_context(|| {
2152            format!(
2153                "update remote_control in local_app_server_feature_enablement in {:?}",
2154                db_path
2155            )
2156        })?;
2157    if updated_rows > 0 {
2158        return Ok(());
2159    }
2160
2161    let mut insert_columns = vec!["feature_name", "enabled", "updated_at"];
2162    let mut insert_values = vec!["?1", "?2", "?3"];
2163    if has_created_at {
2164        insert_columns.push("created_at");
2165        insert_values.push("?3");
2166    }
2167
2168    let sql = format!(
2169        "INSERT INTO local_app_server_feature_enablement ({}) VALUES ({})",
2170        insert_columns.join(", "),
2171        insert_values.join(", ")
2172    );
2173    conn.execute(
2174        sql.as_str(),
2175        rusqlite::params!["remote_control", 1_i64, updated_at],
2176    )
2177    .with_context(|| {
2178        format!(
2179            "upsert remote_control into local_app_server_feature_enablement in {:?}",
2180            db_path
2181        )
2182    })?;
2183
2184    Ok(())
2185}
2186
2187#[derive(Debug)]
2188struct SqliteColumnInfo {
2189    name: String,
2190    not_null: bool,
2191    pk: i32,
2192}
2193
2194fn local_app_server_feature_enablement_columns(
2195    conn: &rusqlite::Connection,
2196) -> Result<Vec<SqliteColumnInfo>> {
2197    let mut stmt = conn
2198        .prepare("PRAGMA table_info(local_app_server_feature_enablement)")
2199        .context("prepare table_info(local_app_server_feature_enablement)")?;
2200    let rows = stmt
2201        .query_map([], |row| {
2202            Ok(SqliteColumnInfo {
2203                name: row.get(1)?,
2204                not_null: row.get::<_, i64>(3)? != 0,
2205                pk: row.get(5)?,
2206            })
2207        })
2208        .context("query table_info(local_app_server_feature_enablement)")?;
2209
2210    let mut columns = Vec::new();
2211    for row in rows {
2212        columns.push(row?);
2213    }
2214    Ok(columns)
2215}
2216
2217fn ensure_required_enablement_columns(columns: &[SqliteColumnInfo]) -> Result<()> {
2218    for required in ["feature_name", "enabled", "updated_at"] {
2219        if !columns.iter().any(|column| column.name == required) {
2220            return Err(anyhow!(
2221                "SQLite table local_app_server_feature_enablement is missing required column `{required}`"
2222            ));
2223        }
2224    }
2225
2226    let optional_supported = ["id", "created_at", "feature_name", "enabled", "updated_at"];
2227    let unsupported_required = columns
2228        .iter()
2229        .filter(|column| column.not_null && column.pk == 0)
2230        .filter(|column| {
2231            !optional_supported
2232                .iter()
2233                .any(|supported| *supported == column.name)
2234        })
2235        .map(|column| column.name.as_str())
2236        .collect::<Vec<_>>();
2237    if !unsupported_required.is_empty() {
2238        return Err(anyhow!(
2239            "SQLite table local_app_server_feature_enablement has unsupported NOT NULL columns without known values: {}",
2240            unsupported_required.join(", ")
2241        ));
2242    }
2243
2244    Ok(())
2245}
2246
2247#[derive(Debug)]
2248struct CodexRemoteControlDbStatus {
2249    table_exists: bool,
2250    enabled: Option<bool>,
2251    updated_at: Option<i64>,
2252}
2253
2254fn read_codex_remote_control_db_status(db_path: &Path) -> Result<CodexRemoteControlDbStatus> {
2255    let conn = rusqlite::Connection::open(db_path)
2256        .with_context(|| format!("open Codex App SQLite database {:?}", db_path))?;
2257    let columns = local_app_server_feature_enablement_columns(&conn)?;
2258    if columns.is_empty() {
2259        return Ok(CodexRemoteControlDbStatus {
2260            table_exists: false,
2261            enabled: None,
2262            updated_at: None,
2263        });
2264    }
2265
2266    ensure_required_enablement_columns(&columns)?;
2267    let mut stmt = conn
2268        .prepare(
2269            "SELECT enabled, updated_at \
2270             FROM local_app_server_feature_enablement \
2271             WHERE feature_name = ?1 \
2272             ORDER BY updated_at DESC \
2273             LIMIT 1",
2274        )
2275        .context("prepare local_app_server_feature_enablement status query")?;
2276    let mut rows = stmt
2277        .query(rusqlite::params!["remote_control"])
2278        .context("query local_app_server_feature_enablement status")?;
2279
2280    if let Some(row) = rows.next()? {
2281        Ok(CodexRemoteControlDbStatus {
2282            table_exists: true,
2283            enabled: Some(row.get::<_, i64>(0)? != 0),
2284            updated_at: row.get(1)?,
2285        })
2286    } else {
2287        Ok(CodexRemoteControlDbStatus {
2288            table_exists: true,
2289            enabled: None,
2290            updated_at: None,
2291        })
2292    }
2293}
2294
2295pub fn codex_remote_control_successful_enablement_log_seen() -> Result<bool> {
2296    let base_dir = codex_config_path()
2297        .parent()
2298        .map(Path::to_path_buf)
2299        .unwrap_or_else(|| PathBuf::from("."));
2300    for candidate in [base_dir.join("log"), base_dir.join("logs")] {
2301        if codex_remote_control_successful_enablement_log_seen_in_dir(candidate.as_path())? {
2302            return Ok(true);
2303        }
2304    }
2305    Ok(false)
2306}
2307
2308fn codex_remote_control_successful_enablement_log_seen_in_dir(log_dir: &Path) -> Result<bool> {
2309    if !log_dir.exists() {
2310        return Ok(false);
2311    }
2312
2313    let mut files = Vec::new();
2314    collect_regular_files(log_dir, &mut files)?;
2315    files.sort_by_key(|path| {
2316        fs::metadata(path)
2317            .and_then(|metadata| metadata.modified())
2318            .ok()
2319    });
2320
2321    for path in files.into_iter().rev().take(20) {
2322        if file_contains_remote_control_success(path.as_path())? {
2323            return Ok(true);
2324        }
2325    }
2326    Ok(false)
2327}
2328
2329fn collect_regular_files(dir: &Path, out: &mut Vec<PathBuf>) -> Result<()> {
2330    for entry in fs::read_dir(dir).with_context(|| format!("read_dir {:?}", dir))? {
2331        let entry = entry.with_context(|| format!("read entry in {:?}", dir))?;
2332        let path = entry.path();
2333        let file_type = entry
2334            .file_type()
2335            .with_context(|| format!("read file type for {:?}", path))?;
2336        if file_type.is_dir() {
2337            collect_regular_files(&path, out)?;
2338        } else if file_type.is_file() {
2339            out.push(path);
2340        }
2341    }
2342    Ok(())
2343}
2344
2345fn file_contains_remote_control_success(path: &Path) -> Result<bool> {
2346    const MAX_BYTES: u64 = 2 * 1024 * 1024;
2347    let mut file = fs::File::open(path).with_context(|| format!("open log {:?}", path))?;
2348    let len = file.metadata()?.len();
2349    if len > MAX_BYTES {
2350        file.seek(SeekFrom::End(-(MAX_BYTES as i64)))
2351            .with_context(|| format!("seek log {:?}", path))?;
2352    }
2353    let mut bytes = Vec::new();
2354    file.read_to_end(&mut bytes)
2355        .with_context(|| format!("read log {:?}", path))?;
2356    let text = String::from_utf8_lossy(&bytes);
2357    Ok(text.contains("experimentalFeature/enablement/set")
2358        && (text.contains("errorCode=null")
2359            || text.contains("\"errorCode\":null")
2360            || text.contains("'errorCode':null")))
2361}
2362
2363#[derive(Debug, Clone)]
2364pub struct ClaudeSwitchStatus {
2365    /// Whether Claude Code currently appears to be configured to use the local codex-helper proxy.
2366    pub enabled: bool,
2367    /// Current `env.ANTHROPIC_BASE_URL` value (if any).
2368    pub base_url: Option<String>,
2369    /// Whether a backup file exists for safe restore.
2370    pub has_backup: bool,
2371    /// The resolved settings file path (settings.json or legacy claude.json).
2372    pub settings_path: PathBuf,
2373}
2374
2375pub fn claude_switch_status() -> Result<ClaudeSwitchStatus> {
2376    let settings_path = claude_settings_path();
2377    let backup_path = claude_settings_backup_path(&settings_path);
2378
2379    if !settings_path.exists() {
2380        return Ok(ClaudeSwitchStatus {
2381            enabled: false,
2382            base_url: None,
2383            has_backup: backup_path.exists(),
2384            settings_path,
2385        });
2386    }
2387
2388    let text = read_settings_text(&settings_path)?;
2389    if text.trim().is_empty() {
2390        return Ok(ClaudeSwitchStatus {
2391            enabled: false,
2392            base_url: None,
2393            has_backup: backup_path.exists(),
2394            settings_path,
2395        });
2396    }
2397
2398    let value: serde_json::Value = match serde_json::from_str(&text) {
2399        Ok(v) => v,
2400        Err(_) => {
2401            return Ok(ClaudeSwitchStatus {
2402                enabled: false,
2403                base_url: None,
2404                has_backup: backup_path.exists(),
2405                settings_path,
2406            });
2407        }
2408    };
2409
2410    let env_obj = value
2411        .as_object()
2412        .and_then(|o| o.get("env"))
2413        .and_then(|v| v.as_object());
2414
2415    let base_url = env_obj
2416        .and_then(|e| e.get("ANTHROPIC_BASE_URL"))
2417        .and_then(|v| v.as_str())
2418        .map(|s| s.to_string());
2419
2420    let enabled = base_url
2421        .as_deref()
2422        .is_some_and(|u| u.contains("127.0.0.1") || u.contains("localhost"));
2423
2424    Ok(ClaudeSwitchStatus {
2425        enabled,
2426        base_url,
2427        has_backup: backup_path.exists(),
2428        settings_path,
2429    })
2430}
2431
2432/// Warn before replacing an existing local Codex proxy patch.
2433pub fn guard_codex_config_before_switch_on_interactive() -> Result<()> {
2434    use std::io::{self, Write};
2435
2436    let cfg_path = codex_config_path();
2437    let state_path = codex_switch_state_path();
2438
2439    if !cfg_path.exists() {
2440        return Ok(());
2441    }
2442
2443    let text = read_config_text(&cfg_path)?;
2444    if text.trim().is_empty() {
2445        return Ok(());
2446    }
2447
2448    let value: Value = match text.parse() {
2449        Ok(value) => value,
2450        Err(_) => return Ok(()),
2451    };
2452    let table = match value.as_table() {
2453        Some(table) => table,
2454        None => return Ok(()),
2455    };
2456
2457    let current_provider = table
2458        .get("model_provider")
2459        .and_then(|value| value.as_str())
2460        .unwrap_or_default();
2461    if current_provider != "codex_proxy" {
2462        return Ok(());
2463    }
2464
2465    let empty_map = toml::map::Map::new();
2466    let providers_table = table
2467        .get("model_providers")
2468        .and_then(|value| value.as_table())
2469        .unwrap_or(&empty_map);
2470    let empty_provider = toml::map::Map::new();
2471    let proxy_table = providers_table
2472        .get("codex_proxy")
2473        .and_then(|value| value.as_table())
2474        .unwrap_or(&empty_provider);
2475
2476    let base_url = proxy_table
2477        .get("base_url")
2478        .and_then(|value| value.as_str())
2479        .unwrap_or_default();
2480    let name = proxy_table
2481        .get("name")
2482        .and_then(|value| value.as_str())
2483        .unwrap_or_default();
2484
2485    let is_local = base_url.contains("127.0.0.1") || base_url.contains("localhost");
2486    let is_helper_name = name == "codex-helper";
2487    if !is_local && !is_helper_name {
2488        return Ok(());
2489    }
2490
2491    if !state_path.exists() {
2492        eprintln!(
2493            "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.",
2494            state_path
2495        );
2496        return Ok(());
2497    }
2498
2499    let is_tty = atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout);
2500    if !is_tty {
2501        eprintln!(
2502            "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.",
2503            state_path
2504        );
2505        return Ok(());
2506    }
2507
2508    eprintln!(
2509        "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] ",
2510        state_path
2511    );
2512    eprint!("> ");
2513    io::stdout().flush().ok();
2514
2515    let mut input = String::new();
2516    if let Err(err) = io::stdin().read_line(&mut input) {
2517        eprintln!("Failed to read input: {err}");
2518        return Ok(());
2519    }
2520    let answer = input.trim();
2521    let yes =
2522        answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes");
2523
2524    if yes {
2525        if let Err(err) = switch_off() {
2526            eprintln!("Failed to disable local Codex proxy patch: {err}");
2527        } else {
2528            eprintln!("Disabled local Codex proxy patch.");
2529        }
2530    } else {
2531        eprintln!("Keeping current Codex config unchanged.");
2532    }
2533
2534    Ok(())
2535}
2536
2537fn read_settings_text(path: &Path) -> Result<String> {
2538    if !path.exists() {
2539        return Ok(String::new());
2540    }
2541    let mut file = fs::File::open(path).with_context(|| format!("open {:?}", path))?;
2542    let mut buf = String::new();
2543    file.read_to_string(&mut buf)
2544        .with_context(|| format!("read {:?}", path))?;
2545    Ok(buf)
2546}
2547
2548pub fn claude_switch_on(port: u16) -> Result<()> {
2549    let settings_path = claude_settings_path();
2550    let backup_path = claude_settings_backup_path(&settings_path);
2551
2552    if settings_path.exists() && !backup_path.exists() {
2553        fs::copy(&settings_path, &backup_path).with_context(|| {
2554            format!(
2555                "backup Claude settings {:?} -> {:?}",
2556                settings_path, backup_path
2557            )
2558        })?;
2559    } else if !settings_path.exists() && !backup_path.exists() {
2560        // If Claude Code has no settings yet, create a sentinel backup so we can restore
2561        // to the "absent" state on claude_switch_off.
2562        if let Some(parent) = backup_path.parent() {
2563            fs::create_dir_all(parent).with_context(|| format!("create_dir_all {:?}", parent))?;
2564        }
2565        fs::write(&backup_path, CLAUDE_ABSENT_BACKUP_SENTINEL)
2566            .with_context(|| format!("write {:?}", backup_path))?;
2567    }
2568
2569    let text = read_settings_text(&settings_path)?;
2570    let mut value: serde_json::Value = if text.trim().is_empty() {
2571        serde_json::json!({})
2572    } else {
2573        serde_json::from_str(&text).with_context(|| format!("parse {:?} as JSON", settings_path))?
2574    };
2575
2576    let obj = value
2577        .as_object_mut()
2578        .ok_or_else(|| anyhow!("Claude settings root must be an object"))?;
2579
2580    let env_val = obj
2581        .entry("env".to_string())
2582        .or_insert_with(|| serde_json::json!({}));
2583    let env_obj = env_val
2584        .as_object_mut()
2585        .ok_or_else(|| anyhow!("Claude settings env must be an object"))?;
2586
2587    let base_url = format!("http://127.0.0.1:{}", port);
2588    env_obj.insert(
2589        "ANTHROPIC_BASE_URL".to_string(),
2590        serde_json::Value::String(base_url),
2591    );
2592
2593    let new_text = serde_json::to_string_pretty(&value)?;
2594    write_text_file(&settings_path, &new_text)
2595        .with_context(|| format!("write {:?}", settings_path))?;
2596
2597    eprintln!(
2598        "[EXPERIMENTAL] Updated {:?} to use local Claude proxy via codex-helper",
2599        settings_path
2600    );
2601    Ok(())
2602}
2603
2604pub fn claude_switch_off() -> Result<()> {
2605    let settings_path = claude_settings_path();
2606    let backup_path = claude_settings_backup_path(&settings_path);
2607    if backup_path.exists() {
2608        let text = read_settings_text(&backup_path)?;
2609        if text.trim() == CLAUDE_ABSENT_BACKUP_SENTINEL {
2610            if settings_path.exists() {
2611                fs::remove_file(&settings_path)
2612                    .with_context(|| format!("remove {:?} (restore absent)", settings_path))?;
2613            }
2614        } else {
2615            atomic_write(&settings_path, &text)
2616                .with_context(|| format!("restore {:?} -> {:?}", backup_path, settings_path))?;
2617            eprintln!(
2618                "[EXPERIMENTAL] Restored Claude settings from backup {:?}",
2619                backup_path
2620            );
2621        }
2622        fs::remove_file(&backup_path)
2623            .with_context(|| format!("remove stale backup {:?}", backup_path))?;
2624    }
2625    Ok(())
2626}
2627
2628#[cfg(test)]
2629#[allow(clippy::items_after_test_module)]
2630mod tests {
2631    use super::*;
2632    use base64::Engine as _;
2633    use serde_json::json;
2634    use std::path::Path;
2635    use std::sync::{Mutex, OnceLock};
2636
2637    struct ScopedEnv {
2638        saved: Vec<(String, Option<String>)>,
2639    }
2640
2641    impl ScopedEnv {
2642        fn new() -> Self {
2643            Self { saved: Vec::new() }
2644        }
2645
2646        unsafe fn set_path(&mut self, key: &str, value: &Path) {
2647            self.saved.push((key.to_string(), std::env::var(key).ok()));
2648            unsafe { std::env::set_var(key, value) };
2649        }
2650
2651        unsafe fn set_str(&mut self, key: &str, value: &str) {
2652            self.saved.push((key.to_string(), std::env::var(key).ok()));
2653            unsafe { std::env::set_var(key, value) };
2654        }
2655    }
2656
2657    impl Drop for ScopedEnv {
2658        fn drop(&mut self) {
2659            for (key, old) in self.saved.drain(..).rev() {
2660                unsafe {
2661                    match old {
2662                        Some(value) => std::env::set_var(&key, value),
2663                        None => std::env::remove_var(&key),
2664                    }
2665                }
2666            }
2667        }
2668    }
2669
2670    fn env_lock() -> std::sync::MutexGuard<'static, ()> {
2671        static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
2672        match LOCK.get_or_init(|| Mutex::new(())).lock() {
2673            Ok(guard) => guard,
2674            Err(err) => err.into_inner(),
2675        }
2676    }
2677
2678    struct TestEnv {
2679        _lock: std::sync::MutexGuard<'static, ()>,
2680        _env: ScopedEnv,
2681        codex_home: PathBuf,
2682        claude_home: PathBuf,
2683    }
2684
2685    fn setup_temp_env() -> TestEnv {
2686        let lock = env_lock();
2687        let root =
2688            std::env::temp_dir().join(format!("codex-helper-switch-test-{}", uuid::Uuid::new_v4()));
2689        std::fs::create_dir_all(&root).expect("create temp root");
2690
2691        let codex_home = root.join(".codex");
2692        let claude_home = root.join(".claude");
2693        let proxy_home = root.join(".codex-helper");
2694        std::fs::create_dir_all(&codex_home).expect("create temp codex home");
2695        std::fs::create_dir_all(&claude_home).expect("create temp claude home");
2696        std::fs::create_dir_all(&proxy_home).expect("create temp proxy home");
2697
2698        let mut scoped = ScopedEnv::new();
2699        unsafe {
2700            scoped.set_path("CODEX_HOME", &codex_home);
2701            scoped.set_path("CLAUDE_HOME", &claude_home);
2702            scoped.set_path("CODEX_HELPER_HOME", &proxy_home);
2703            scoped.set_path("HOME", &root);
2704            scoped.set_path("USERPROFILE", &root);
2705            scoped.set_str("CODEX_HELPER_IMAGEGEN_TEST_KEY", "sk-relay-test");
2706            scoped.set_str("CODEX_HELPER_MISSING_IMAGEGEN_KEY", "");
2707        }
2708
2709        TestEnv {
2710            _lock: lock,
2711            _env: scoped,
2712            codex_home,
2713            claude_home,
2714        }
2715    }
2716
2717    fn write_file(path: &Path, content: &str) {
2718        if let Some(parent) = path.parent() {
2719            std::fs::create_dir_all(parent).expect("create parent directories");
2720        }
2721        std::fs::write(path, content).expect("write test file");
2722    }
2723
2724    fn read_file(path: &Path) -> String {
2725        std::fs::read_to_string(path).expect("read test file")
2726    }
2727
2728    fn write_helper_codex_config(env: &TestEnv, content: &str) {
2729        let proxy_home = env.codex_home.parent().unwrap().join(".codex-helper");
2730        write_file(&proxy_home.join("config.toml"), content.trim_start());
2731    }
2732
2733    fn write_helper_codex_config_with_env_auth(env: &TestEnv) {
2734        write_helper_codex_config(
2735            env,
2736            r#"
2737version = 5
2738
2739[codex.providers.relay]
2740base_url = "https://relay.example/v1"
2741auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
2742
2743[codex.routing]
2744entry = "main"
2745
2746[codex.routing.routes.main]
2747strategy = "ordered-failover"
2748children = ["relay"]
2749"#,
2750        );
2751    }
2752
2753    fn fake_chatgpt_jwt(email: &str, account_id: &str, plan_type: &str) -> String {
2754        let header = json!({
2755            "alg": "none",
2756            "typ": "JWT",
2757        });
2758        let payload = json!({
2759            "email": email,
2760            "https://api.openai.com/auth": {
2761                "chatgpt_account_id": account_id,
2762                "chatgpt_plan_type": plan_type,
2763            },
2764        });
2765        let encode = |value: serde_json::Value| {
2766            let bytes = serde_json::to_vec(&value).expect("serialize JWT part");
2767            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
2768        };
2769        format!("{}.{}.sig", encode(header), encode(payload))
2770    }
2771
2772    fn chatgpt_auth_json(email: &str, account_id: &str, plan_type: &str) -> String {
2773        let id_token = fake_chatgpt_jwt(email, account_id, plan_type);
2774        serde_json::to_string_pretty(&json!({
2775            "auth_mode": "chatgpt",
2776            "OPENAI_API_KEY": "sk-platform-onboarding",
2777            "tokens": {
2778                "id_token": id_token,
2779                "access_token": "chatgpt-access-token",
2780                "refresh_token": "chatgpt-refresh-token",
2781                "account_id": account_id,
2782            },
2783            "last_refresh": "2026-05-17T00:00:00Z",
2784        }))
2785        .expect("serialize chatgpt auth fixture")
2786    }
2787
2788    fn chatgpt_auth_json_without_plan(email: &str, account_id: &str) -> String {
2789        let header = json!({
2790            "alg": "none",
2791            "typ": "JWT",
2792        });
2793        let payload = json!({
2794            "email": email,
2795            "https://api.openai.com/auth": {
2796                "chatgpt_account_id": account_id,
2797            },
2798        });
2799        let encode = |value: serde_json::Value| {
2800            let bytes = serde_json::to_vec(&value).expect("serialize JWT part");
2801            base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
2802        };
2803        let id_token = format!("{}.{}.sig", encode(header), encode(payload));
2804        serde_json::to_string_pretty(&json!({
2805            "auth_mode": "chatgpt",
2806            "OPENAI_API_KEY": null,
2807            "tokens": {
2808                "id_token": id_token,
2809                "access_token": "chatgpt-access-token",
2810                "refresh_token": "chatgpt-refresh-token",
2811                "account_id": account_id,
2812            },
2813            "last_refresh": "2026-05-17T00:00:00Z",
2814        }))
2815        .expect("serialize chatgpt auth fixture")
2816    }
2817
2818    #[test]
2819    fn codex_switch_on_preserves_unrelated_toml_comments_and_fields() {
2820        let env = setup_temp_env();
2821        let cfg_path = env.codex_home.join("config.toml");
2822
2823        let original = r#"# top comment
2824model_provider = "openai"
2825
2826[model_providers.openai]
2827# keep this comment
2828name = "OpenAI"
2829base_url = "https://api.openai.com/v1"
2830request_max_retries = 3
2831
2832[projects."D:\\Work"]
2833trust_level = "trusted"
2834"#;
2835
2836        write_file(&cfg_path, original);
2837        switch_on(3211).expect("switch_on should preserve editable TOML structure");
2838
2839        let updated = read_file(&cfg_path);
2840        assert!(updated.contains("# top comment"));
2841        assert!(updated.contains("# keep this comment"));
2842        assert!(updated.contains("[model_providers.openai]"));
2843        assert!(updated.contains("[projects."));
2844        assert!(updated.contains("model_provider = \"codex_proxy\""));
2845        assert!(updated.contains("[model_providers.codex_proxy]"));
2846        assert!(updated.contains("base_url = \"http://127.0.0.1:3211\""));
2847    }
2848
2849    #[test]
2850    fn codex_switch_on_keeps_existing_proxy_retry_setting() {
2851        let text = r#"
2852model_provider = "codex_proxy"
2853
2854[model_providers.codex_proxy]
2855name = "custom"
2856base_url = "http://127.0.0.1:1111"
2857request_max_retries = 5
2858"#;
2859
2860        let updated = switch_on_codex_toml_with_mode(text, 3333, CodexPatchMode::Default)
2861            .expect("switch_on should update the local proxy provider in place");
2862
2863        assert!(updated.contains("request_max_retries = 5"));
2864        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
2865        assert!(updated.contains("name = \"codex-helper\""));
2866    }
2867
2868    #[test]
2869    fn codex_patch_plan_chatgpt_bridge_keeps_account_auth_shape_and_safe_write_order() {
2870        let plan = CodexPatchPlan::for_switch_on(
2871            CodexPatchMode::ChatGptBridge,
2872            CodexSwitchOptions::default(),
2873        )
2874        .expect("chatgpt bridge patch plan");
2875
2876        assert_eq!(plan.provider().provider_name(), "codex-helper");
2877        assert_eq!(plan.provider().requires_openai_auth().value(), Some(true));
2878        assert_eq!(plan.provider().supports_websockets().value(), Some(false));
2879        assert_eq!(plan.auth(), CodexAuthPatchPlan::PatchChatGptBridge);
2880        assert_eq!(
2881            plan.effect_order(),
2882            CodexSwitchOnEffectOrder::StateConfigAuth
2883        );
2884        assert!(
2885            !plan.requires_bridge_runtime_ready(),
2886            "chatgpt bridge validates the existing Codex auth.json instead of helper upstream auth"
2887        );
2888    }
2889
2890    #[test]
2891    fn codex_patch_plan_official_relay_uses_openai_identity_without_auth_facade() {
2892        let plan = CodexPatchPlan::for_switch_on(
2893            CodexPatchMode::OfficialRelayBridge,
2894            CodexSwitchOptions {
2895                responses_websocket: true,
2896            },
2897        )
2898        .expect("official relay patch plan");
2899
2900        assert_eq!(plan.provider().provider_name(), "OpenAI");
2901        assert_eq!(plan.provider().requires_openai_auth().value(), None);
2902        assert_eq!(plan.provider().supports_websockets().value(), Some(true));
2903        assert_eq!(
2904            plan.auth(),
2905            CodexAuthPatchPlan::RestoreOriginalIfHelperPatched
2906        );
2907        assert_eq!(
2908            plan.effect_order(),
2909            CodexSwitchOnEffectOrder::ConfigAuthState
2910        );
2911        assert!(plan.requires_bridge_runtime_ready());
2912    }
2913
2914    #[test]
2915    fn codex_patch_plan_official_imagegen_combines_openai_identity_and_auth_facade() {
2916        let plan = CodexPatchPlan::for_switch_on(
2917            CodexPatchMode::OfficialImagegenBridge,
2918            CodexSwitchOptions::default(),
2919        )
2920        .expect("official imagegen patch plan");
2921
2922        assert_eq!(plan.provider().provider_name(), "OpenAI");
2923        assert_eq!(plan.provider().requires_openai_auth().value(), None);
2924        assert_eq!(plan.provider().supports_websockets().value(), Some(false));
2925        assert_eq!(plan.auth(), CodexAuthPatchPlan::PatchImagegenFacade);
2926        assert_eq!(
2927            plan.effect_order(),
2928            CodexSwitchOnEffectOrder::StateConfigAuth
2929        );
2930        assert!(plan.requires_bridge_runtime_ready());
2931    }
2932
2933    #[test]
2934    fn codex_patch_plan_rejects_websocket_transport_without_official_identity() {
2935        let err = CodexPatchPlan::for_switch_on(
2936            CodexPatchMode::ImagegenBridge,
2937            CodexSwitchOptions {
2938                responses_websocket: true,
2939            },
2940        )
2941        .expect_err("websocket transport should be official identity only");
2942
2943        assert!(
2944            err.to_string()
2945                .contains("requires --preset official-relay or --preset official-imagegen")
2946        );
2947    }
2948
2949    #[test]
2950    fn codex_switch_on_toml_is_driven_by_patch_plan() {
2951        let text = r#"
2952model_provider = "codex_proxy"
2953
2954[model_providers.codex_proxy]
2955name = "codex-helper"
2956base_url = "http://127.0.0.1:1111"
2957wire_api = "responses"
2958requires_openai_auth = true
2959supports_websockets = false
2960"#;
2961        let plan = CodexPatchPlan::for_switch_on(
2962            CodexPatchMode::OfficialRelayBridge,
2963            CodexSwitchOptions {
2964                responses_websocket: true,
2965            },
2966        )
2967        .expect("official relay patch plan");
2968
2969        let updated =
2970            switch_on_codex_toml_with_plan(text, 3333, plan).expect("plan-driven TOML patch");
2971
2972        assert!(updated.contains("name = \"OpenAI\""));
2973        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
2974        assert!(updated.contains("supports_websockets = true"));
2975        assert!(!updated.contains("requires_openai_auth"));
2976    }
2977
2978    #[test]
2979    fn codex_auth_edit_for_switch_on_plan_restores_prior_facade_from_current_text() {
2980        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
2981        let mut state = CodexSwitchState {
2982            version: 2,
2983            patch_mode: Some(CodexPatchMode::OfficialImagegenBridge),
2984            responses_websocket: false,
2985            original_config_absent: false,
2986            original_model_provider: None,
2987            original_codex_proxy: None,
2988            had_model_providers: false,
2989            original_auth_json_absent: false,
2990            original_auth_json: Some(original_auth.clone()),
2991            patched_auth_json: Some("{}".to_string()),
2992        };
2993        let plan = CodexPatchPlan::for_switch_on(
2994            CodexPatchMode::OfficialRelayBridge,
2995            CodexSwitchOptions::default(),
2996        )
2997        .expect("official relay patch plan");
2998
2999        let edit = auth_restore_edit_from_state_and_current(&mut state, Some("{}"));
3000
3001        assert_eq!(
3002            plan.auth(),
3003            CodexAuthPatchPlan::RestoreOriginalIfHelperPatched
3004        );
3005        assert_eq!(edit, CodexAuthEdit::Write(original_auth));
3006        assert_eq!(state.patched_auth_json, None);
3007    }
3008
3009    #[test]
3010    fn codex_switch_on_chatgpt_bridge_sets_openai_auth_flags() {
3011        let updated = switch_on_codex_toml_with_mode("", 3333, CodexPatchMode::ChatGptBridge)
3012            .expect("switch_on should write chatgpt bridge fields");
3013
3014        assert!(updated.contains("model_provider = \"codex_proxy\""));
3015        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3016        assert!(updated.contains("requires_openai_auth = true"));
3017        assert!(updated.contains("supports_websockets = false"));
3018    }
3019
3020    #[test]
3021    fn codex_switch_on_imagegen_bridge_uses_default_proxy_flags() {
3022        let updated = switch_on_codex_toml_with_mode("", 3333, CodexPatchMode::ImagegenBridge)
3023            .expect("switch_on should write imagegen bridge fields");
3024
3025        assert!(updated.contains("model_provider = \"codex_proxy\""));
3026        assert!(updated.contains("name = \"codex-helper\""));
3027        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3028        assert!(!updated.contains("requires_openai_auth"));
3029        assert!(!updated.contains("supports_websockets"));
3030    }
3031
3032    #[test]
3033    fn codex_switch_on_official_relay_bridge_sets_openai_name_and_disables_websockets() {
3034        let updated = switch_on_codex_toml_with_mode("", 3333, CodexPatchMode::OfficialRelayBridge)
3035            .expect("switch_on should write official relay preset fields");
3036
3037        assert!(updated.contains("model_provider = \"codex_proxy\""));
3038        assert!(updated.contains("name = \"OpenAI\""));
3039        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3040        assert!(updated.contains("wire_api = \"responses\""));
3041        assert!(updated.contains("supports_websockets = false"));
3042        assert!(!updated.contains("requires_openai_auth"));
3043    }
3044
3045    #[test]
3046    fn codex_switch_on_official_relay_bridge_can_enable_responses_websocket() {
3047        let updated = switch_on_codex_toml_with_options(
3048            "",
3049            3333,
3050            CodexPatchMode::OfficialRelayBridge,
3051            CodexSwitchOptions {
3052                responses_websocket: true,
3053            },
3054        )
3055        .expect("switch_on should write official relay preset fields with websocket transport");
3056
3057        assert!(updated.contains("model_provider = \"codex_proxy\""));
3058        assert!(updated.contains("name = \"OpenAI\""));
3059        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3060        assert!(updated.contains("wire_api = \"responses\""));
3061        assert!(updated.contains("supports_websockets = true"));
3062        assert!(!updated.contains("requires_openai_auth"));
3063    }
3064
3065    #[test]
3066    fn codex_switch_on_official_imagegen_bridge_sets_openai_name_and_disables_websockets() {
3067        let updated =
3068            switch_on_codex_toml_with_mode("", 3333, CodexPatchMode::OfficialImagegenBridge)
3069                .expect("switch_on should write official imagegen preset fields");
3070
3071        assert!(updated.contains("model_provider = \"codex_proxy\""));
3072        assert!(updated.contains("name = \"OpenAI\""));
3073        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3074        assert!(updated.contains("wire_api = \"responses\""));
3075        assert!(updated.contains("supports_websockets = false"));
3076        assert!(!updated.contains("requires_openai_auth"));
3077    }
3078
3079    #[test]
3080    fn codex_switch_on_official_imagegen_bridge_can_enable_responses_websocket() {
3081        let updated = switch_on_codex_toml_with_options(
3082            "",
3083            3333,
3084            CodexPatchMode::OfficialImagegenBridge,
3085            CodexSwitchOptions {
3086                responses_websocket: true,
3087            },
3088        )
3089        .expect("switch_on should write official imagegen preset fields with websocket transport");
3090
3091        assert!(updated.contains("model_provider = \"codex_proxy\""));
3092        assert!(updated.contains("name = \"OpenAI\""));
3093        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3094        assert!(updated.contains("wire_api = \"responses\""));
3095        assert!(updated.contains("supports_websockets = true"));
3096        assert!(!updated.contains("requires_openai_auth"));
3097    }
3098
3099    #[test]
3100    fn empty_auth_json_facade_detection_uses_json_semantics() {
3101        assert!(auth_json_is_empty_chatgpt_facade_text(Some("{}")));
3102        assert!(auth_json_is_empty_chatgpt_facade_text(Some("{\n}\n")));
3103        assert!(!auth_json_is_empty_chatgpt_facade_text(Some(
3104            r#"{"auth_mode":"chatgpt"}"#
3105        )));
3106        assert!(!auth_json_is_empty_chatgpt_facade_text(None));
3107        assert!(!auth_json_is_empty_chatgpt_facade_text(Some("not-json")));
3108    }
3109
3110    #[test]
3111    fn codex_switch_on_default_removes_bridge_only_flags() {
3112        let text = r#"
3113model_provider = "codex_proxy"
3114
3115[model_providers.codex_proxy]
3116name = "codex-helper"
3117base_url = "http://127.0.0.1:1111"
3118wire_api = "responses"
3119requires_openai_auth = true
3120supports_websockets = false
3121"#;
3122
3123        let updated = switch_on_codex_toml_with_mode(text, 3333, CodexPatchMode::Default)
3124            .expect("switch_on should switch local proxy back to default mode");
3125
3126        assert!(updated.contains("base_url = \"http://127.0.0.1:3333\""));
3127        assert!(!updated.contains("requires_openai_auth"));
3128        assert!(!updated.contains("supports_websockets"));
3129    }
3130
3131    #[test]
3132    fn remote_control_config_patch_sets_remote_connections_and_removes_removed_key() {
3133        let text = r#"
3134[features]
3135remote_control = true
3136apps = true
3137"#;
3138
3139        let updated = ensure_codex_remote_connections_feature_in_toml(text)
3140            .expect("remote-control config patch should parse");
3141
3142        assert!(updated.contains("remote_connections = true"));
3143        assert!(updated.contains("apps = true"));
3144        assert!(!updated.contains("remote_control = true"));
3145        assert!(
3146            codex_remote_connections_feature_enabled_from_toml(&updated)
3147                .expect("remote_connections should parse")
3148        );
3149        assert!(
3150            !codex_remote_control_feature_present_in_toml(&updated)
3151                .expect("remote_control should parse")
3152        );
3153    }
3154
3155    #[test]
3156    fn codex_remote_control_enable_patches_config_backs_up_db_and_writes_sqlite() {
3157        let env = setup_temp_env();
3158        let cfg_path = env.codex_home.join("config.toml");
3159        let db_dir = env.codex_home.join("sqlite");
3160        let db_path = db_dir.join("codex-dev.db");
3161        std::fs::create_dir_all(&db_dir).expect("create sqlite dir");
3162        write_file(
3163            &cfg_path,
3164            r#"
3165[features]
3166remote_control = true
3167"#
3168            .trim_start(),
3169        );
3170
3171        {
3172            let conn = rusqlite::Connection::open(&db_path).expect("open test sqlite");
3173            conn.execute(
3174                "CREATE TABLE local_app_server_feature_enablement (
3175                    feature_name TEXT PRIMARY KEY,
3176                    enabled INTEGER NOT NULL,
3177                    updated_at INTEGER NOT NULL
3178                )",
3179                [],
3180            )
3181            .expect("create feature table");
3182            conn.execute(
3183                "INSERT INTO local_app_server_feature_enablement (feature_name, enabled, updated_at)
3184                 VALUES (?1, ?2, ?3)",
3185                rusqlite::params!["remote_control", 0_i64, 7_i64],
3186            )
3187            .expect("seed feature row");
3188        }
3189
3190        let result =
3191            codex_remote_control_enable().expect("remote-control enablement should succeed");
3192
3193        assert!(result.backup_path.exists(), "backup should be created");
3194        assert!(result.status.remote_connections_enabled);
3195        assert!(!result.status.remote_control_config_present);
3196        assert_eq!(result.status.db_enabled, Some(true));
3197        assert!(result.status.db_updated_at.unwrap_or_default() > 7);
3198
3199        let updated_cfg = read_file(&cfg_path);
3200        assert!(updated_cfg.contains("remote_connections = true"));
3201        assert!(!updated_cfg.contains("remote_control = true"));
3202    }
3203
3204    #[test]
3205    fn codex_remote_control_enable_does_not_patch_config_when_db_schema_is_missing() {
3206        let env = setup_temp_env();
3207        let cfg_path = env.codex_home.join("config.toml");
3208        let db_dir = env.codex_home.join("sqlite");
3209        let db_path = db_dir.join("codex-dev.db");
3210        std::fs::create_dir_all(&db_dir).expect("create sqlite dir");
3211        write_file(
3212            &cfg_path,
3213            r#"
3214[features]
3215apps = true
3216"#
3217            .trim_start(),
3218        );
3219        {
3220            let _conn = rusqlite::Connection::open(&db_path).expect("open empty test sqlite");
3221        }
3222        let original = read_file(&cfg_path);
3223
3224        let err = codex_remote_control_enable()
3225            .expect_err("remote-control enablement should reject missing feature table");
3226
3227        assert!(
3228            err.to_string()
3229                .contains("local_app_server_feature_enablement")
3230        );
3231        assert_eq!(read_file(&cfg_path), original);
3232    }
3233
3234    #[test]
3235    fn remote_control_log_scan_detects_successful_enablement_set() {
3236        let env = setup_temp_env();
3237        let log_dir = env.codex_home.join("log");
3238        std::fs::create_dir_all(&log_dir).expect("create log dir");
3239        write_file(
3240            &log_dir.join("codex-app.log"),
3241            r#"{"method":"experimentalFeature/enablement/set","errorCode":null}"#,
3242        );
3243
3244        assert!(
3245            codex_remote_control_successful_enablement_log_seen_in_dir(&log_dir)
3246                .expect("scan logs")
3247        );
3248    }
3249
3250    fn startup_readiness_input(changed: bool) -> CodexStartupReadinessInput {
3251        CodexStartupReadinessInput {
3252            expected_port: 3211,
3253            expected_patch_mode: CodexPatchMode::Default,
3254            expected_responses_websocket: false,
3255            client_state_changed_this_startup: changed,
3256            switch_error: None,
3257        }
3258    }
3259
3260    fn has_startup_issue(
3261        report: &CodexStartupReadiness,
3262        kind: CodexStartupReadinessIssueKind,
3263    ) -> bool {
3264        report.issues.iter().any(|issue| issue.kind == kind)
3265    }
3266
3267    #[test]
3268    fn codex_tui_startup_readiness_is_quiet_when_switch_is_ready() {
3269        let env = setup_temp_env();
3270        let cfg_path = env.codex_home.join("config.toml");
3271        write_file(
3272            &cfg_path,
3273            r#"
3274model_provider = "openai"
3275
3276[model_providers.openai]
3277name = "openai"
3278base_url = "https://api.openai.com/v1"
3279"#
3280            .trim_start(),
3281        );
3282        switch_on(3211).expect("switch_on should create a valid local proxy patch");
3283
3284        let report = codex_tui_startup_readiness(startup_readiness_input(false));
3285
3286        assert_eq!(report.issues, Vec::new());
3287    }
3288
3289    #[test]
3290    fn codex_tui_startup_readiness_reports_client_state_changed() {
3291        let env = setup_temp_env();
3292        let cfg_path = env.codex_home.join("config.toml");
3293        write_file(
3294            &cfg_path,
3295            r#"
3296model_provider = "openai"
3297
3298[model_providers.openai]
3299name = "openai"
3300base_url = "https://api.openai.com/v1"
3301"#
3302            .trim_start(),
3303        );
3304        switch_on(3211).expect("switch_on should create a valid local proxy patch");
3305
3306        let report = codex_tui_startup_readiness(startup_readiness_input(true));
3307
3308        assert!(has_startup_issue(
3309            &report,
3310            CodexStartupReadinessIssueKind::ClientStateChanged
3311        ));
3312        assert_eq!(report.issues.len(), 1);
3313    }
3314
3315    #[test]
3316    fn codex_tui_startup_readiness_warns_for_local_proxy_without_switch_state() {
3317        let env = setup_temp_env();
3318        let cfg_path = env.codex_home.join("config.toml");
3319        write_file(
3320            &cfg_path,
3321            r#"
3322model_provider = "codex_proxy"
3323
3324[model_providers.codex_proxy]
3325name = "codex-helper"
3326base_url = "http://127.0.0.1:3211"
3327wire_api = "responses"
3328"#
3329            .trim_start(),
3330        );
3331
3332        let report = codex_tui_startup_readiness(startup_readiness_input(false));
3333
3334        assert!(has_startup_issue(
3335            &report,
3336            CodexStartupReadinessIssueKind::MissingSwitchState
3337        ));
3338    }
3339
3340    #[test]
3341    fn codex_tui_startup_readiness_warns_official_bridge_with_preferred_group_multi_provider() {
3342        let env = setup_temp_env();
3343        write_helper_codex_config(
3344            &env,
3345            r#"
3346version = 5
3347
3348[codex.providers.a]
3349base_url = "https://a.example/v1"
3350auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
3351
3352[codex.providers.b]
3353base_url = "https://b.example/v1"
3354auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
3355
3356[codex.routing]
3357entry = "main"
3358affinity_policy = "preferred-group"
3359
3360[codex.routing.routes.main]
3361strategy = "ordered-failover"
3362children = ["a", "b"]
3363"#,
3364        );
3365        let cfg_path = env.codex_home.join("config.toml");
3366        write_file(
3367            &cfg_path,
3368            r#"
3369model_provider = "codex_proxy"
3370
3371[model_providers.codex_proxy]
3372name = "OpenAI"
3373base_url = "http://127.0.0.1:3211"
3374wire_api = "responses"
3375supports_websockets = false
3376"#
3377            .trim_start(),
3378        );
3379
3380        let mut input = startup_readiness_input(false);
3381        input.expected_patch_mode = CodexPatchMode::OfficialRelayBridge;
3382        let report = codex_tui_startup_readiness(input);
3383
3384        assert!(has_startup_issue(
3385            &report,
3386            CodexStartupReadinessIssueKind::OfficialRelayAffinityPolicy
3387        ));
3388    }
3389
3390    #[test]
3391    fn codex_tui_startup_readiness_accepts_official_bridge_with_fallback_sticky() {
3392        let env = setup_temp_env();
3393        write_helper_codex_config(
3394            &env,
3395            r#"
3396version = 5
3397
3398[codex.providers.a]
3399base_url = "https://a.example/v1"
3400auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
3401
3402[codex.providers.b]
3403base_url = "https://b.example/v1"
3404auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
3405
3406[codex.routing]
3407entry = "main"
3408affinity_policy = "fallback-sticky"
3409
3410[codex.routing.routes.main]
3411strategy = "ordered-failover"
3412children = ["a", "b"]
3413"#,
3414        );
3415        let cfg_path = env.codex_home.join("config.toml");
3416        write_file(
3417            &cfg_path,
3418            r#"
3419model_provider = "codex_proxy"
3420
3421[model_providers.codex_proxy]
3422name = "OpenAI"
3423base_url = "http://127.0.0.1:3211"
3424wire_api = "responses"
3425supports_websockets = false
3426"#
3427            .trim_start(),
3428        );
3429
3430        let mut input = startup_readiness_input(false);
3431        input.expected_patch_mode = CodexPatchMode::OfficialRelayBridge;
3432        let report = codex_tui_startup_readiness(input);
3433
3434        assert!(!has_startup_issue(
3435            &report,
3436            CodexStartupReadinessIssueKind::OfficialRelayAffinityPolicy
3437        ));
3438    }
3439
3440    #[test]
3441    fn codex_tui_startup_readiness_warns_when_remote_control_log_is_unconfirmed() {
3442        let env = setup_temp_env();
3443        let cfg_path = env.codex_home.join("config.toml");
3444        let db_dir = env.codex_home.join("sqlite");
3445        let db_path = db_dir.join("codex-dev.db");
3446        std::fs::create_dir_all(&db_dir).expect("create sqlite dir");
3447        write_file(
3448            &cfg_path,
3449            r#"
3450model_provider = "openai"
3451
3452[features]
3453remote_connections = true
3454
3455[model_providers.openai]
3456name = "openai"
3457base_url = "https://api.openai.com/v1"
3458"#
3459            .trim_start(),
3460        );
3461        {
3462            let conn = rusqlite::Connection::open(&db_path).expect("open test sqlite");
3463            conn.execute(
3464                "CREATE TABLE local_app_server_feature_enablement (
3465                    feature_name TEXT PRIMARY KEY,
3466                    enabled INTEGER NOT NULL,
3467                    updated_at INTEGER NOT NULL
3468                )",
3469                [],
3470            )
3471            .expect("create feature table");
3472            conn.execute(
3473                "INSERT INTO local_app_server_feature_enablement (feature_name, enabled, updated_at)
3474                 VALUES (?1, ?2, ?3)",
3475                rusqlite::params!["remote_control", 1_i64, 7_i64],
3476            )
3477            .expect("seed feature row");
3478        }
3479        switch_on(3211).expect("switch_on should preserve features");
3480
3481        let report = codex_tui_startup_readiness(startup_readiness_input(false));
3482
3483        assert!(has_startup_issue(
3484            &report,
3485            CodexStartupReadinessIssueKind::RemoteControlLogUnconfirmed
3486        ));
3487    }
3488
3489    #[test]
3490    fn chatgpt_bridge_auth_patch_preserves_other_auth_json_fields() {
3491        let mut input: serde_json::Value =
3492            serde_json::from_str(&chatgpt_auth_json("user@example.com", "account-1", "plus"))
3493                .expect("valid fixture");
3494        input["last_refresh"] = json!(123);
3495        input["unrelated"] = json!("keep");
3496
3497        let updated = chatgpt_bridge_auth_json_text(&serde_json::to_string_pretty(&input).unwrap())
3498            .expect("auth json patch should preserve unrelated fields");
3499        let value: serde_json::Value = serde_json::from_str(&updated).expect("valid json");
3500        let object = value.as_object().expect("root object");
3501
3502        assert_eq!(
3503            object.get("auth_mode").and_then(|value| value.as_str()),
3504            Some("chatgpt")
3505        );
3506        assert!(
3507            object
3508                .get("OPENAI_API_KEY")
3509                .is_some_and(|value| value.is_null())
3510        );
3511        assert_eq!(
3512            object
3513                .get("tokens")
3514                .and_then(|value| value.get("access_token"))
3515                .and_then(|value| value.as_str()),
3516            Some("chatgpt-access-token")
3517        );
3518        assert_eq!(
3519            object
3520                .get("tokens")
3521                .and_then(|value| value.get("account_id"))
3522                .and_then(|value| value.as_str()),
3523            Some("account-1")
3524        );
3525        assert_eq!(
3526            object.get("last_refresh").and_then(|value| value.as_i64()),
3527            Some(123)
3528        );
3529        assert_eq!(
3530            object.get("unrelated").and_then(|value| value.as_str()),
3531            Some("keep")
3532        );
3533    }
3534
3535    #[test]
3536    fn chatgpt_bridge_auth_patch_rejects_incomplete_login_state() {
3537        let err = chatgpt_bridge_auth_json_text(r#"{"auth_mode":"chatgpt","OPENAI_API_KEY":null}"#)
3538            .expect_err("empty ChatGPT auth state should be rejected");
3539
3540        let message = err.to_string();
3541        assert!(message.contains("complete ChatGPT login state"));
3542        assert!(message.contains("tokens.id_token"));
3543        assert!(message.contains("tokens.access_token"));
3544        assert!(message.contains("last_refresh"));
3545    }
3546
3547    #[test]
3548    fn chatgpt_bridge_auth_patch_rejects_api_key_only_auth() {
3549        let err = chatgpt_bridge_auth_json_text(
3550            r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-old","account_id":"acct_1"}"#,
3551        )
3552        .expect_err("API key auth should not be converted into fake ChatGPT auth");
3553
3554        assert!(
3555            err.to_string()
3556                .contains("Open Codex and sign in with ChatGPT first")
3557        );
3558    }
3559
3560    #[test]
3561    fn chatgpt_bridge_auth_patch_accepts_chatgpt_auth_without_plan_claim() {
3562        let input = chatgpt_auth_json_without_plan("user@example.com", "acct_1");
3563
3564        let updated = chatgpt_bridge_auth_json_text(&input)
3565            .expect("Codex maps missing ChatGPT plan claims to unknown");
3566        let value: serde_json::Value = serde_json::from_str(&updated).expect("valid json");
3567
3568        assert_eq!(
3569            value.get("auth_mode").and_then(|value| value.as_str()),
3570            Some("chatgpt")
3571        );
3572        assert!(
3573            value
3574                .get("OPENAI_API_KEY")
3575                .is_some_and(|value| value.is_null())
3576        );
3577    }
3578
3579    #[test]
3580    fn imagegen_bridge_auth_patch_writes_empty_chatgpt_facade() {
3581        let updated = imagegen_bridge_auth_json_text().expect("serialize facade auth");
3582        let value: serde_json::Value = serde_json::from_str(&updated).expect("valid json");
3583
3584        assert!(
3585            value.as_object().is_some_and(serde_json::Map::is_empty),
3586            "imagegen bridge must rely on Codex's default ChatGPT mode fallback, not an explicit auth_mode field"
3587        );
3588    }
3589
3590    #[test]
3591    fn helper_auth_patch_match_uses_json_semantics() {
3592        assert!(auth_json_matches_helper_patch(
3593            Some(r#"{"auth_mode":"chatgpt"}"#),
3594            "{\n  \"auth_mode\": \"chatgpt\"\n}"
3595        ));
3596        assert!(!auth_json_matches_helper_patch(
3597            Some(r#"{"auth_mode":"apikey"}"#),
3598            "{\n  \"auth_mode\": \"chatgpt\"\n}"
3599        ));
3600    }
3601
3602    #[test]
3603    fn imagegen_bridge_ready_check_rejects_missing_codex_upstreams() {
3604        let _env = setup_temp_env();
3605
3606        let err = ensure_imagegen_bridge_runtime_ready()
3607            .expect_err("imagegen bridge should require codex-helper upstreams");
3608
3609        assert!(
3610            err.to_string()
3611                .contains("requires at least one enabled Codex upstream")
3612        );
3613    }
3614
3615    #[test]
3616    fn imagegen_bridge_ready_check_rejects_unresolved_upstream_env() {
3617        let env = setup_temp_env();
3618        write_helper_codex_config(
3619            &env,
3620            r#"
3621version = 5
3622
3623[codex.providers.relay]
3624base_url = "https://relay.example/v1"
3625auth_token_env = "CODEX_HELPER_MISSING_IMAGEGEN_KEY"
3626
3627[codex.routing]
3628entry = "main"
3629
3630[codex.routing.routes.main]
3631strategy = "ordered-failover"
3632children = ["relay"]
3633"#,
3634        );
3635
3636        let err = ensure_imagegen_bridge_runtime_ready()
3637            .expect_err("imagegen bridge should require resolved upstream auth");
3638
3639        let message = err.to_string();
3640        assert!(message.contains("no enabled Codex upstream credential is available"));
3641        assert!(message.contains("CODEX_HELPER_MISSING_IMAGEGEN_KEY"));
3642    }
3643
3644    #[test]
3645    fn official_relay_bridge_ready_check_rejects_unresolved_upstream_env() {
3646        let env = setup_temp_env();
3647        write_helper_codex_config(
3648            &env,
3649            r#"
3650version = 5
3651
3652[codex.providers.relay]
3653base_url = "https://relay.example/v1"
3654auth_token_env = "CODEX_HELPER_MISSING_IMAGEGEN_KEY"
3655
3656[codex.routing]
3657entry = "main"
3658
3659[codex.routing.routes.main]
3660strategy = "ordered-failover"
3661children = ["relay"]
3662"#,
3663        );
3664
3665        let err = ensure_bridge_runtime_ready(CodexPatchMode::OfficialRelayBridge)
3666            .expect_err("official relay preset should require resolved upstream auth");
3667
3668        let message = err.to_string();
3669        assert!(message.contains("official-relay"));
3670        assert!(message.contains("no enabled Codex upstream credential is available"));
3671        assert!(message.contains("CODEX_HELPER_MISSING_IMAGEGEN_KEY"));
3672    }
3673
3674    #[test]
3675    fn official_imagegen_bridge_ready_check_rejects_unresolved_upstream_env() {
3676        let env = setup_temp_env();
3677        write_helper_codex_config(
3678            &env,
3679            r#"
3680version = 5
3681
3682[codex.providers.relay]
3683base_url = "https://relay.example/v1"
3684auth_token_env = "CODEX_HELPER_MISSING_IMAGEGEN_KEY"
3685
3686[codex.routing]
3687entry = "main"
3688
3689[codex.routing.routes.main]
3690strategy = "ordered-failover"
3691children = ["relay"]
3692"#,
3693        );
3694
3695        let err = ensure_bridge_runtime_ready(CodexPatchMode::OfficialImagegenBridge)
3696            .expect_err("official imagegen preset should require resolved upstream auth");
3697
3698        let message = err.to_string();
3699        assert!(message.contains("official-imagegen"));
3700        assert!(message.contains("no enabled Codex upstream credential is available"));
3701        assert!(message.contains("CODEX_HELPER_MISSING_IMAGEGEN_KEY"));
3702    }
3703
3704    #[test]
3705    fn official_relay_bridge_with_responses_websocket_ready_check_rejects_unresolved_upstream_env()
3706    {
3707        let env = setup_temp_env();
3708        write_helper_codex_config(
3709            &env,
3710            r#"
3711version = 5
3712
3713[codex.providers.relay]
3714base_url = "https://relay.example/v1"
3715auth_token_env = "CODEX_HELPER_MISSING_IMAGEGEN_KEY"
3716
3717[codex.routing]
3718entry = "main"
3719
3720[codex.routing.routes.main]
3721strategy = "ordered-failover"
3722children = ["relay"]
3723"#,
3724        );
3725
3726        let err = switch_on_with_options(
3727            3211,
3728            CodexPatchMode::OfficialRelayBridge,
3729            CodexSwitchOptions {
3730                responses_websocket: true,
3731            },
3732        )
3733        .expect_err("official relay preset with websocket should require resolved upstream auth");
3734
3735        let message = err.to_string();
3736        assert!(message.contains("official-relay"));
3737        assert!(message.contains("no enabled Codex upstream credential is available"));
3738        assert!(message.contains("CODEX_HELPER_MISSING_IMAGEGEN_KEY"));
3739    }
3740
3741    #[test]
3742    fn codex_switch_on_chatgpt_bridge_patches_auth_json() {
3743        let env = setup_temp_env();
3744        let cfg_path = env.codex_home.join("config.toml");
3745        let auth_path = env.codex_home.join("auth.json");
3746
3747        write_file(
3748            &cfg_path,
3749            r#"
3750model_provider = "openai"
3751
3752[model_providers.openai]
3753name = "openai"
3754base_url = "https://api.openai.com/v1"
3755"#
3756            .trim_start(),
3757        );
3758        write_file(
3759            &auth_path,
3760            &chatgpt_auth_json("user@example.com", "acct_1", "plus"),
3761        );
3762
3763        switch_on_with_mode(3211, CodexPatchMode::ChatGptBridge)
3764            .expect("switch_on bridge should patch config and auth");
3765
3766        let updated_cfg = read_file(&cfg_path);
3767        assert!(updated_cfg.contains("requires_openai_auth = true"));
3768        assert!(updated_cfg.contains("supports_websockets = false"));
3769
3770        let updated_auth: serde_json::Value =
3771            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
3772        assert_eq!(
3773            updated_auth
3774                .get("auth_mode")
3775                .and_then(|value| value.as_str()),
3776            Some("chatgpt")
3777        );
3778        assert!(
3779            updated_auth
3780                .get("OPENAI_API_KEY")
3781                .is_some_and(|value| value.is_null())
3782        );
3783        assert_eq!(
3784            updated_auth
3785                .get("tokens")
3786                .and_then(|value| value.get("account_id"))
3787                .and_then(|value| value.as_str()),
3788            Some("acct_1")
3789        );
3790    }
3791
3792    #[test]
3793    fn codex_switch_on_imagegen_bridge_patches_auth_json_and_records_mode() {
3794        let env = setup_temp_env();
3795        write_helper_codex_config_with_env_auth(&env);
3796        let cfg_path = env.codex_home.join("config.toml");
3797        let auth_path = env.codex_home.join("auth.json");
3798        let state_path = env.codex_home.join("codex-helper-switch-state.json");
3799        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
3800
3801        write_file(
3802            &cfg_path,
3803            r#"
3804model_provider = "openai"
3805
3806[model_providers.openai]
3807name = "openai"
3808base_url = "https://api.openai.com/v1"
3809"#
3810            .trim_start(),
3811        );
3812        write_file(&auth_path, &original_auth);
3813
3814        switch_on_with_mode(3211, CodexPatchMode::ImagegenBridge)
3815            .expect("switch_on imagegen bridge should patch config and auth");
3816
3817        let updated_cfg = read_file(&cfg_path);
3818        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
3819        assert!(!updated_cfg.contains("requires_openai_auth"));
3820
3821        let updated_auth: serde_json::Value =
3822            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
3823        assert!(
3824            updated_auth
3825                .as_object()
3826                .is_some_and(serde_json::Map::is_empty)
3827        );
3828
3829        let state_text = read_file(&state_path);
3830        let state: serde_json::Value = serde_json::from_str(&state_text).expect("valid state");
3831        assert_eq!(
3832            state.get("patch_mode").and_then(|value| value.as_str()),
3833            Some("imagegen-bridge")
3834        );
3835        assert_eq!(
3836            state
3837                .get("original_auth_json")
3838                .and_then(|value| value.as_str()),
3839            Some(original_auth.as_str())
3840        );
3841        assert_eq!(
3842            state
3843                .get("patched_auth_json")
3844                .and_then(|value| value.as_str()),
3845            Some("{}")
3846        );
3847    }
3848
3849    #[test]
3850    fn codex_switch_on_official_relay_bridge_records_mode_without_auth_json_patch() {
3851        let env = setup_temp_env();
3852        write_helper_codex_config_with_env_auth(&env);
3853        let cfg_path = env.codex_home.join("config.toml");
3854        let auth_path = env.codex_home.join("auth.json");
3855        let state_path = env.codex_home.join("codex-helper-switch-state.json");
3856        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
3857
3858        write_file(
3859            &cfg_path,
3860            r#"
3861model_provider = "openai"
3862
3863[model_providers.openai]
3864name = "openai"
3865base_url = "https://api.openai.com/v1"
3866"#
3867            .trim_start(),
3868        );
3869        write_file(&auth_path, &original_auth);
3870
3871        switch_on_with_mode(3211, CodexPatchMode::OfficialRelayBridge)
3872            .expect("switch_on official relay preset should patch config");
3873
3874        let updated_cfg = read_file(&cfg_path);
3875        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
3876        assert!(updated_cfg.contains("name = \"OpenAI\""));
3877        assert!(updated_cfg.contains("supports_websockets = false"));
3878        assert!(!updated_cfg.contains("requires_openai_auth"));
3879        assert_eq!(read_file(&auth_path), original_auth);
3880
3881        let state_text = read_file(&state_path);
3882        let state: serde_json::Value = serde_json::from_str(&state_text).expect("valid state");
3883        assert_eq!(
3884            state.get("patch_mode").and_then(|value| value.as_str()),
3885            Some("official-relay-bridge")
3886        );
3887        assert!(state.get("patched_auth_json").is_none());
3888
3889        let status = codex_switch_status().expect("status should load");
3890        assert_eq!(status.patch_mode, Some(CodexPatchMode::OfficialRelayBridge));
3891        assert_eq!(status.requires_openai_auth, None);
3892        assert_eq!(status.supports_websockets, Some(false));
3893    }
3894
3895    #[test]
3896    fn codex_switch_on_official_relay_bridge_records_transport_option_without_auth_json_patch() {
3897        let env = setup_temp_env();
3898        write_helper_codex_config_with_env_auth(&env);
3899        let cfg_path = env.codex_home.join("config.toml");
3900        let auth_path = env.codex_home.join("auth.json");
3901        let state_path = env.codex_home.join("codex-helper-switch-state.json");
3902        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
3903
3904        write_file(
3905            &cfg_path,
3906            r#"
3907model_provider = "openai"
3908
3909[model_providers.openai]
3910name = "openai"
3911base_url = "https://api.openai.com/v1"
3912"#
3913            .trim_start(),
3914        );
3915        write_file(&auth_path, &original_auth);
3916
3917        switch_on_with_options(
3918            3211,
3919            CodexPatchMode::OfficialRelayBridge,
3920            CodexSwitchOptions {
3921                responses_websocket: true,
3922            },
3923        )
3924        .expect("switch_on official relay preset should patch config with websocket transport");
3925
3926        let updated_cfg = read_file(&cfg_path);
3927        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
3928        assert!(updated_cfg.contains("name = \"OpenAI\""));
3929        assert!(updated_cfg.contains("supports_websockets = true"));
3930        assert!(!updated_cfg.contains("requires_openai_auth"));
3931        assert_eq!(read_file(&auth_path), original_auth);
3932
3933        let state_text = read_file(&state_path);
3934        let state: serde_json::Value = serde_json::from_str(&state_text).expect("valid state");
3935        assert_eq!(
3936            state.get("patch_mode").and_then(|value| value.as_str()),
3937            Some("official-relay-bridge")
3938        );
3939        assert_eq!(
3940            state
3941                .get("responses_websocket")
3942                .and_then(|value| value.as_bool()),
3943            Some(true)
3944        );
3945        assert!(state.get("patched_auth_json").is_none());
3946
3947        let status = codex_switch_status().expect("status should load");
3948        assert_eq!(status.patch_mode, Some(CodexPatchMode::OfficialRelayBridge));
3949        assert_eq!(status.requires_openai_auth, None);
3950        assert_eq!(status.supports_websockets, Some(true));
3951    }
3952
3953    #[test]
3954    fn codex_switch_on_official_imagegen_bridge_records_mode_and_patches_auth_json() {
3955        let env = setup_temp_env();
3956        write_helper_codex_config_with_env_auth(&env);
3957        let cfg_path = env.codex_home.join("config.toml");
3958        let auth_path = env.codex_home.join("auth.json");
3959        let state_path = env.codex_home.join("codex-helper-switch-state.json");
3960        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
3961
3962        write_file(
3963            &cfg_path,
3964            r#"
3965model_provider = "openai"
3966
3967[model_providers.openai]
3968name = "openai"
3969base_url = "https://api.openai.com/v1"
3970"#
3971            .trim_start(),
3972        );
3973        write_file(&auth_path, &original_auth);
3974
3975        switch_on_with_mode(3211, CodexPatchMode::OfficialImagegenBridge)
3976            .expect("switch_on official imagegen preset should patch config and auth");
3977
3978        let updated_cfg = read_file(&cfg_path);
3979        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
3980        assert!(updated_cfg.contains("name = \"OpenAI\""));
3981        assert!(updated_cfg.contains("supports_websockets = false"));
3982        assert!(!updated_cfg.contains("requires_openai_auth"));
3983
3984        let updated_auth: serde_json::Value =
3985            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
3986        assert!(
3987            updated_auth
3988                .as_object()
3989                .is_some_and(serde_json::Map::is_empty)
3990        );
3991
3992        let state_text = read_file(&state_path);
3993        let state: serde_json::Value = serde_json::from_str(&state_text).expect("valid state");
3994        assert_eq!(
3995            state.get("patch_mode").and_then(|value| value.as_str()),
3996            Some("official-imagegen-bridge")
3997        );
3998        assert_eq!(
3999            state
4000                .get("original_auth_json")
4001                .and_then(|value| value.as_str()),
4002            Some(original_auth.as_str())
4003        );
4004        assert_eq!(
4005            state
4006                .get("patched_auth_json")
4007                .and_then(|value| value.as_str()),
4008            Some("{}")
4009        );
4010
4011        let status = codex_switch_status().expect("status should load");
4012        assert_eq!(
4013            status.patch_mode,
4014            Some(CodexPatchMode::OfficialImagegenBridge)
4015        );
4016        assert_eq!(status.requires_openai_auth, None);
4017        assert_eq!(status.supports_websockets, Some(false));
4018    }
4019
4020    #[test]
4021    fn codex_switch_on_official_imagegen_bridge_records_transport_option_and_patches_auth_json() {
4022        let env = setup_temp_env();
4023        write_helper_codex_config_with_env_auth(&env);
4024        let cfg_path = env.codex_home.join("config.toml");
4025        let auth_path = env.codex_home.join("auth.json");
4026        let state_path = env.codex_home.join("codex-helper-switch-state.json");
4027        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4028
4029        write_file(
4030            &cfg_path,
4031            r#"
4032model_provider = "openai"
4033
4034[model_providers.openai]
4035name = "openai"
4036base_url = "https://api.openai.com/v1"
4037"#
4038            .trim_start(),
4039        );
4040        write_file(&auth_path, &original_auth);
4041
4042        switch_on_with_options(
4043            3211,
4044            CodexPatchMode::OfficialImagegenBridge,
4045            CodexSwitchOptions {
4046                responses_websocket: true,
4047            },
4048        )
4049        .expect("switch_on official imagegen preset should patch config and auth with websocket transport");
4050
4051        let updated_cfg = read_file(&cfg_path);
4052        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
4053        assert!(updated_cfg.contains("name = \"OpenAI\""));
4054        assert!(updated_cfg.contains("supports_websockets = true"));
4055        assert!(!updated_cfg.contains("requires_openai_auth"));
4056
4057        let updated_auth: serde_json::Value =
4058            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
4059        assert!(
4060            updated_auth
4061                .as_object()
4062                .is_some_and(serde_json::Map::is_empty)
4063        );
4064
4065        let state_text = read_file(&state_path);
4066        let state: serde_json::Value = serde_json::from_str(&state_text).expect("valid state");
4067        assert_eq!(
4068            state.get("patch_mode").and_then(|value| value.as_str()),
4069            Some("official-imagegen-bridge")
4070        );
4071        assert_eq!(
4072            state
4073                .get("responses_websocket")
4074                .and_then(|value| value.as_bool()),
4075            Some(true)
4076        );
4077        assert_eq!(
4078            state
4079                .get("original_auth_json")
4080                .and_then(|value| value.as_str()),
4081            Some(original_auth.as_str())
4082        );
4083        assert_eq!(
4084            state
4085                .get("patched_auth_json")
4086                .and_then(|value| value.as_str()),
4087            Some("{}")
4088        );
4089
4090        let status = codex_switch_status().expect("status should load");
4091        assert_eq!(
4092            status.patch_mode,
4093            Some(CodexPatchMode::OfficialImagegenBridge)
4094        );
4095        assert_eq!(status.requires_openai_auth, None);
4096        assert_eq!(status.supports_websockets, Some(true));
4097    }
4098
4099    #[test]
4100    fn codex_switch_on_with_configured_preset_uses_helper_client_patch() {
4101        let env = setup_temp_env();
4102        write_helper_codex_config(
4103            &env,
4104            r#"
4105version = 5
4106
4107[codex.client_patch]
4108preset = "official-imagegen"
4109responses_websocket = true
4110
4111[codex.providers.relay]
4112base_url = "https://relay.example/v1"
4113auth_token_env = "CODEX_HELPER_IMAGEGEN_TEST_KEY"
4114
4115[codex.routing]
4116entry = "main"
4117
4118[codex.routing.routes.main]
4119strategy = "ordered-failover"
4120children = ["relay"]
4121"#,
4122        );
4123        let cfg_path = env.codex_home.join("config.toml");
4124        let auth_path = env.codex_home.join("auth.json");
4125        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4126
4127        write_file(
4128            &cfg_path,
4129            r#"
4130model_provider = "openai"
4131
4132[model_providers.openai]
4133name = "openai"
4134base_url = "https://api.openai.com/v1"
4135"#
4136            .trim_start(),
4137        );
4138        write_file(&auth_path, &original_auth);
4139
4140        switch_on_with_configured_preset(3211)
4141            .expect("configured official-imagegen preset should patch config and auth");
4142
4143        let updated_cfg = read_file(&cfg_path);
4144        assert!(updated_cfg.contains("model_provider = \"codex_proxy\""));
4145        assert!(updated_cfg.contains("name = \"OpenAI\""));
4146        assert!(updated_cfg.contains("base_url = \"http://127.0.0.1:3211\""));
4147        assert!(updated_cfg.contains("supports_websockets = true"));
4148        assert!(!updated_cfg.contains("requires_openai_auth"));
4149
4150        let updated_auth: serde_json::Value =
4151            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
4152        assert!(
4153            updated_auth
4154                .as_object()
4155                .is_some_and(serde_json::Map::is_empty)
4156        );
4157
4158        let status = codex_switch_status().expect("status should load");
4159        assert_eq!(
4160            status.patch_mode,
4161            Some(CodexPatchMode::OfficialImagegenBridge)
4162        );
4163        assert_eq!(status.provider_name.as_deref(), Some("OpenAI"));
4164        assert_eq!(status.supports_websockets, Some(true));
4165    }
4166
4167    #[test]
4168    fn codex_switch_status_infers_official_relay_bridge_without_state() {
4169        let env = setup_temp_env();
4170        let cfg_path = env.codex_home.join("config.toml");
4171        write_file(
4172            &cfg_path,
4173            r#"
4174model_provider = "codex_proxy"
4175
4176[model_providers.codex_proxy]
4177name = "OpenAI"
4178base_url = "http://127.0.0.1:3211"
4179wire_api = "responses"
4180supports_websockets = false
4181"#
4182            .trim_start(),
4183        );
4184
4185        let status = codex_switch_status().expect("status should load");
4186
4187        assert!(status.enabled);
4188        assert_eq!(status.patch_mode, Some(CodexPatchMode::OfficialRelayBridge));
4189        assert_eq!(status.supports_websockets, Some(false));
4190        assert_eq!(status.requires_openai_auth, None);
4191        assert!(!status.has_switch_state);
4192    }
4193
4194    #[test]
4195    fn codex_switch_status_keeps_websocket_as_transport_for_official_relay_without_state() {
4196        let env = setup_temp_env();
4197        let cfg_path = env.codex_home.join("config.toml");
4198        write_file(
4199            &cfg_path,
4200            r#"
4201model_provider = "codex_proxy"
4202
4203[model_providers.codex_proxy]
4204name = "OpenAI"
4205base_url = "http://127.0.0.1:3211"
4206wire_api = "responses"
4207supports_websockets = true
4208"#
4209            .trim_start(),
4210        );
4211
4212        let status = codex_switch_status().expect("status should load");
4213
4214        assert!(status.enabled);
4215        assert_eq!(status.patch_mode, Some(CodexPatchMode::OfficialRelayBridge));
4216        assert_eq!(status.supports_websockets, Some(true));
4217        assert_eq!(status.requires_openai_auth, None);
4218        assert!(!status.has_switch_state);
4219    }
4220
4221    #[test]
4222    fn codex_switch_status_infers_official_imagegen_bridge_from_empty_auth_facade_without_state() {
4223        let env = setup_temp_env();
4224        let cfg_path = env.codex_home.join("config.toml");
4225        let auth_path = env.codex_home.join("auth.json");
4226        write_file(
4227            &cfg_path,
4228            r#"
4229model_provider = "codex_proxy"
4230
4231[model_providers.codex_proxy]
4232name = "OpenAI"
4233base_url = "http://127.0.0.1:3211"
4234wire_api = "responses"
4235supports_websockets = false
4236"#
4237            .trim_start(),
4238        );
4239        write_file(&auth_path, "{}");
4240
4241        let status = codex_switch_status().expect("status should load");
4242
4243        assert!(status.enabled);
4244        assert_eq!(
4245            status.patch_mode,
4246            Some(CodexPatchMode::OfficialImagegenBridge)
4247        );
4248        assert_eq!(status.supports_websockets, Some(false));
4249        assert_eq!(status.requires_openai_auth, None);
4250        assert!(!status.has_switch_state);
4251    }
4252
4253    #[test]
4254    fn codex_switch_status_keeps_websocket_as_transport_for_official_imagegen_without_state() {
4255        let env = setup_temp_env();
4256        let cfg_path = env.codex_home.join("config.toml");
4257        let auth_path = env.codex_home.join("auth.json");
4258        write_file(
4259            &cfg_path,
4260            r#"
4261model_provider = "codex_proxy"
4262
4263[model_providers.codex_proxy]
4264name = "OpenAI"
4265base_url = "http://127.0.0.1:3211"
4266wire_api = "responses"
4267supports_websockets = true
4268"#
4269            .trim_start(),
4270        );
4271        write_file(&auth_path, "{}");
4272
4273        let status = codex_switch_status().expect("status should load");
4274
4275        assert!(status.enabled);
4276        assert_eq!(
4277            status.patch_mode,
4278            Some(CodexPatchMode::OfficialImagegenBridge)
4279        );
4280        assert_eq!(status.supports_websockets, Some(true));
4281        assert_eq!(status.requires_openai_auth, None);
4282        assert!(!status.has_switch_state);
4283    }
4284
4285    #[test]
4286    fn codex_bridge_diagnostics_reports_ready_official_imagegen_bridge() {
4287        let env = setup_temp_env();
4288        write_helper_codex_config_with_env_auth(&env);
4289        let cfg_path = env.codex_home.join("config.toml");
4290        let auth_path = env.codex_home.join("auth.json");
4291        write_file(
4292            &cfg_path,
4293            r#"
4294model_provider = "codex_proxy"
4295
4296[model_providers.codex_proxy]
4297name = "OpenAI"
4298base_url = "http://127.0.0.1:3211"
4299wire_api = "responses"
4300supports_websockets = false
4301"#
4302            .trim_start(),
4303        );
4304        write_file(&auth_path, "{}");
4305
4306        let diagnostics = codex_bridge_diagnostics();
4307
4308        assert_eq!(
4309            diagnostics.patch_mode,
4310            Some(CodexPatchMode::OfficialImagegenBridge)
4311        );
4312        assert!(diagnostics.enabled);
4313        assert!(diagnostics.remote_compaction_v1_ready);
4314        assert!(diagnostics.imagegen_facade_ready);
4315        assert!(diagnostics.upstream_auth_ready);
4316        assert!(!diagnostics.remote_compaction_v2_enabled);
4317        assert_eq!(diagnostics.worst_status(), CodexBridgeDiagnosticStatus::Ok);
4318    }
4319
4320    #[test]
4321    fn codex_bridge_diagnostics_reports_ready_official_relay_with_responses_websocket() {
4322        let env = setup_temp_env();
4323        write_helper_codex_config_with_env_auth(&env);
4324        let cfg_path = env.codex_home.join("config.toml");
4325        write_file(
4326            &cfg_path,
4327            r#"
4328model_provider = "codex_proxy"
4329
4330[model_providers.codex_proxy]
4331name = "OpenAI"
4332base_url = "http://127.0.0.1:3211"
4333wire_api = "responses"
4334supports_websockets = true
4335"#
4336            .trim_start(),
4337        );
4338
4339        let diagnostics = codex_bridge_diagnostics();
4340
4341        assert_eq!(
4342            diagnostics.patch_mode,
4343            Some(CodexPatchMode::OfficialRelayBridge)
4344        );
4345        assert!(diagnostics.enabled);
4346        assert!(diagnostics.remote_compaction_v1_ready);
4347        assert!(!diagnostics.imagegen_facade_ready);
4348        assert!(diagnostics.upstream_auth_ready);
4349        let websocket_checks = diagnostics
4350            .checks
4351            .iter()
4352            .filter(|check| check.id == "codex_bridge.responses_websocket")
4353            .collect::<Vec<_>>();
4354        assert!(
4355            websocket_checks
4356                .iter()
4357                .any(|check| check.status == CodexBridgeDiagnosticStatus::Ok),
4358            "expected at least one ready websocket check: {websocket_checks:?}"
4359        );
4360        assert_eq!(
4361            diagnostics.worst_status(),
4362            CodexBridgeDiagnosticStatus::Info
4363        );
4364    }
4365
4366    #[test]
4367    fn codex_bridge_diagnostics_warns_when_remote_compaction_v2_is_enabled() {
4368        let env = setup_temp_env();
4369        write_helper_codex_config_with_env_auth(&env);
4370        let cfg_path = env.codex_home.join("config.toml");
4371        let auth_path = env.codex_home.join("auth.json");
4372        write_file(
4373            &cfg_path,
4374            r#"
4375model_provider = "codex_proxy"
4376
4377[features]
4378remote_compaction_v2 = true
4379
4380[model_providers.codex_proxy]
4381name = "OpenAI"
4382base_url = "http://127.0.0.1:3211"
4383wire_api = "responses"
4384supports_websockets = false
4385"#
4386            .trim_start(),
4387        );
4388        write_file(&auth_path, "{}");
4389
4390        let diagnostics = codex_bridge_diagnostics();
4391
4392        assert!(diagnostics.remote_compaction_v2_enabled);
4393        assert_eq!(
4394            diagnostics.worst_status(),
4395            CodexBridgeDiagnosticStatus::Warn
4396        );
4397        let v2 = diagnostics
4398            .checks
4399            .iter()
4400            .find(|check| check.id == "codex_bridge.remote_compaction_v2")
4401            .expect("v2 check");
4402        assert_eq!(v2.status, CodexBridgeDiagnosticStatus::Warn);
4403        assert!(v2.message.contains("remote_compaction_v2 is enabled"));
4404    }
4405
4406    #[test]
4407    fn codex_bridge_diagnostics_fails_imagegen_when_auth_facade_is_missing() {
4408        let env = setup_temp_env();
4409        write_helper_codex_config_with_env_auth(&env);
4410        let cfg_path = env.codex_home.join("config.toml");
4411        write_file(
4412            &cfg_path,
4413            r#"
4414model_provider = "codex_proxy"
4415
4416[model_providers.codex_proxy]
4417name = "OpenAI"
4418base_url = "http://127.0.0.1:3211"
4419wire_api = "responses"
4420supports_websockets = false
4421"#
4422            .trim_start(),
4423        );
4424        let state = CodexSwitchState {
4425            patch_mode: Some(CodexPatchMode::OfficialImagegenBridge),
4426            ..CodexSwitchState::from_codex_config_text("", true).expect("state")
4427        };
4428        write_codex_switch_state(&state).expect("write state");
4429
4430        let diagnostics = codex_bridge_diagnostics();
4431
4432        assert_eq!(
4433            diagnostics.patch_mode,
4434            Some(CodexPatchMode::OfficialImagegenBridge)
4435        );
4436        assert!(!diagnostics.imagegen_facade_ready);
4437        let imagegen = diagnostics
4438            .checks
4439            .iter()
4440            .find(|check| check.id == "codex_bridge.imagegen_facade")
4441            .expect("imagegen check");
4442        assert_eq!(imagegen.status, CodexBridgeDiagnosticStatus::Fail);
4443    }
4444
4445    #[test]
4446    fn codex_bridge_runtime_auth_snapshot_reports_missing_env_names() {
4447        let env = setup_temp_env();
4448        write_helper_codex_config(
4449            &env,
4450            r#"
4451version = 5
4452
4453[codex.providers.relay]
4454base_url = "https://relay.example/v1"
4455auth_token_env = "CODEX_HELPER_MISSING_IMAGEGEN_KEY"
4456
4457[codex.routing]
4458entry = "main"
4459
4460[codex.routing.routes.main]
4461strategy = "ordered-failover"
4462children = ["relay"]
4463"#,
4464        );
4465
4466        let snapshot = codex_bridge_runtime_auth_snapshot().expect("snapshot");
4467
4468        assert_eq!(snapshot.routable_upstreams, 1);
4469        assert_eq!(snapshot.authed_upstreams, 0);
4470        assert_eq!(
4471            snapshot.missing_env,
4472            vec!["CODEX_HELPER_MISSING_IMAGEGEN_KEY".to_string()]
4473        );
4474    }
4475
4476    #[test]
4477    fn codex_switch_default_restores_imagegen_bridge_auth_json() {
4478        let env = setup_temp_env();
4479        write_helper_codex_config_with_env_auth(&env);
4480        let cfg_path = env.codex_home.join("config.toml");
4481        let auth_path = env.codex_home.join("auth.json");
4482        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4483
4484        write_file(
4485            &cfg_path,
4486            r#"
4487model_provider = "openai"
4488
4489[model_providers.openai]
4490name = "openai"
4491base_url = "https://api.openai.com/v1"
4492"#
4493            .trim_start(),
4494        );
4495        write_file(&auth_path, &original_auth);
4496
4497        switch_on_with_mode(3211, CodexPatchMode::ImagegenBridge)
4498            .expect("switch_on imagegen bridge should patch auth");
4499        assert_ne!(read_file(&auth_path), original_auth);
4500
4501        switch_on_with_mode(3211, CodexPatchMode::Default)
4502            .expect("switching to default should restore auth");
4503
4504        assert_eq!(read_file(&auth_path), original_auth);
4505        let status = codex_switch_status().expect("status should load");
4506        assert_eq!(status.patch_mode, Some(CodexPatchMode::Default));
4507    }
4508
4509    #[test]
4510    fn codex_switch_default_restores_official_imagegen_bridge_auth_json() {
4511        let env = setup_temp_env();
4512        write_helper_codex_config_with_env_auth(&env);
4513        let cfg_path = env.codex_home.join("config.toml");
4514        let auth_path = env.codex_home.join("auth.json");
4515        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4516
4517        write_file(
4518            &cfg_path,
4519            r#"
4520model_provider = "openai"
4521
4522[model_providers.openai]
4523name = "openai"
4524base_url = "https://api.openai.com/v1"
4525"#
4526            .trim_start(),
4527        );
4528        write_file(&auth_path, &original_auth);
4529
4530        switch_on_with_mode(3211, CodexPatchMode::OfficialImagegenBridge)
4531            .expect("switch_on official imagegen preset should patch auth");
4532        assert_ne!(read_file(&auth_path), original_auth);
4533
4534        switch_on_with_mode(3211, CodexPatchMode::Default)
4535            .expect("switching to default should restore auth");
4536
4537        assert_eq!(read_file(&auth_path), original_auth);
4538        let status = codex_switch_status().expect("status should load");
4539        assert_eq!(status.patch_mode, Some(CodexPatchMode::Default));
4540    }
4541
4542    #[test]
4543    fn codex_switch_default_restores_official_imagegen_bridge_auth_json_with_responses_websocket() {
4544        let env = setup_temp_env();
4545        write_helper_codex_config_with_env_auth(&env);
4546        let cfg_path = env.codex_home.join("config.toml");
4547        let auth_path = env.codex_home.join("auth.json");
4548        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4549
4550        write_file(
4551            &cfg_path,
4552            r#"
4553model_provider = "openai"
4554
4555[model_providers.openai]
4556name = "openai"
4557base_url = "https://api.openai.com/v1"
4558"#
4559            .trim_start(),
4560        );
4561        write_file(&auth_path, &original_auth);
4562
4563        switch_on_with_options(
4564            3211,
4565            CodexPatchMode::OfficialImagegenBridge,
4566            CodexSwitchOptions {
4567                responses_websocket: true,
4568            },
4569        )
4570        .expect("switch_on official imagegen preset should patch auth with websocket transport");
4571        assert_ne!(read_file(&auth_path), original_auth);
4572
4573        switch_on_with_mode(3211, CodexPatchMode::Default)
4574            .expect("switching to default should restore auth");
4575
4576        assert_eq!(read_file(&auth_path), original_auth);
4577        let status = codex_switch_status().expect("status should load");
4578        assert_eq!(status.patch_mode, Some(CodexPatchMode::Default));
4579    }
4580
4581    #[test]
4582    fn codex_switch_off_restores_imagegen_bridge_auth_json() {
4583        let env = setup_temp_env();
4584        write_helper_codex_config_with_env_auth(&env);
4585        let cfg_path = env.codex_home.join("config.toml");
4586        let auth_path = env.codex_home.join("auth.json");
4587        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4588
4589        write_file(
4590            &cfg_path,
4591            r#"
4592model_provider = "openai"
4593
4594[model_providers.openai]
4595name = "openai"
4596base_url = "https://api.openai.com/v1"
4597"#
4598            .trim_start(),
4599        );
4600        write_file(&auth_path, &original_auth);
4601
4602        switch_on_with_mode(3211, CodexPatchMode::ImagegenBridge)
4603            .expect("switch_on imagegen bridge should patch auth");
4604        switch_off().expect("switch_off should restore auth");
4605
4606        assert_eq!(read_file(&auth_path), original_auth);
4607        assert!(read_file(&cfg_path).contains("model_provider = \"openai\""));
4608    }
4609
4610    #[test]
4611    fn codex_switch_off_does_not_restore_auth_if_user_changed_it() {
4612        let env = setup_temp_env();
4613        write_helper_codex_config_with_env_auth(&env);
4614        let cfg_path = env.codex_home.join("config.toml");
4615        let auth_path = env.codex_home.join("auth.json");
4616        let user_auth = r#"{"auth_mode":"apikey","OPENAI_API_KEY":"sk-user"}"#;
4617
4618        write_file(
4619            &cfg_path,
4620            r#"
4621model_provider = "openai"
4622
4623[model_providers.openai]
4624name = "openai"
4625base_url = "https://api.openai.com/v1"
4626"#
4627            .trim_start(),
4628        );
4629        write_file(
4630            &auth_path,
4631            &chatgpt_auth_json("user@example.com", "acct_1", "plus"),
4632        );
4633
4634        switch_on_with_mode(3211, CodexPatchMode::ImagegenBridge)
4635            .expect("switch_on imagegen bridge should patch auth");
4636        write_file(&auth_path, user_auth);
4637        switch_off().expect("switch_off should leave user auth alone");
4638
4639        assert_eq!(read_file(&auth_path), user_auth);
4640    }
4641
4642    #[test]
4643    fn codex_switch_imagegen_to_chatgpt_bridge_uses_original_auth_json() {
4644        let env = setup_temp_env();
4645        write_helper_codex_config_with_env_auth(&env);
4646        let cfg_path = env.codex_home.join("config.toml");
4647        let auth_path = env.codex_home.join("auth.json");
4648        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4649
4650        write_file(
4651            &cfg_path,
4652            r#"
4653model_provider = "openai"
4654
4655[model_providers.openai]
4656name = "openai"
4657base_url = "https://api.openai.com/v1"
4658"#
4659            .trim_start(),
4660        );
4661        write_file(&auth_path, &original_auth);
4662
4663        switch_on_with_mode(3211, CodexPatchMode::ImagegenBridge)
4664            .expect("switch_on imagegen bridge should patch auth");
4665        switch_on_with_mode(3211, CodexPatchMode::ChatGptBridge)
4666            .expect("switching to chatgpt bridge should use original auth snapshot");
4667
4668        let updated_auth: serde_json::Value =
4669            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
4670        assert_eq!(
4671            updated_auth
4672                .get("tokens")
4673                .and_then(|value| value.get("account_id"))
4674                .and_then(|value| value.as_str()),
4675            Some("acct_1")
4676        );
4677        assert!(
4678            updated_auth
4679                .get("OPENAI_API_KEY")
4680                .is_some_and(|value| value.is_null())
4681        );
4682    }
4683
4684    #[test]
4685    fn codex_switch_official_imagegen_to_chatgpt_bridge_uses_original_auth_json() {
4686        let env = setup_temp_env();
4687        write_helper_codex_config_with_env_auth(&env);
4688        let cfg_path = env.codex_home.join("config.toml");
4689        let auth_path = env.codex_home.join("auth.json");
4690        let original_auth = chatgpt_auth_json("user@example.com", "acct_1", "plus");
4691
4692        write_file(
4693            &cfg_path,
4694            r#"
4695model_provider = "openai"
4696
4697[model_providers.openai]
4698name = "openai"
4699base_url = "https://api.openai.com/v1"
4700"#
4701            .trim_start(),
4702        );
4703        write_file(&auth_path, &original_auth);
4704
4705        switch_on_with_mode(3211, CodexPatchMode::OfficialImagegenBridge)
4706            .expect("switch_on official imagegen preset should patch auth");
4707        switch_on_with_mode(3211, CodexPatchMode::ChatGptBridge)
4708            .expect("switching to chatgpt bridge should use original auth snapshot");
4709
4710        let updated_cfg = read_file(&cfg_path);
4711        assert!(updated_cfg.contains("name = \"codex-helper\""));
4712        assert!(updated_cfg.contains("requires_openai_auth = true"));
4713
4714        let updated_auth: serde_json::Value =
4715            serde_json::from_str(&read_file(&auth_path)).expect("valid auth json");
4716        assert_eq!(
4717            updated_auth
4718                .get("tokens")
4719                .and_then(|value| value.get("account_id"))
4720                .and_then(|value| value.as_str()),
4721            Some("acct_1")
4722        );
4723        assert!(
4724            updated_auth
4725                .get("OPENAI_API_KEY")
4726                .is_some_and(|value| value.is_null())
4727        );
4728    }
4729
4730    #[test]
4731    fn codex_switch_on_chatgpt_bridge_does_not_rewrite_already_patched_auth_json() {
4732        let env = setup_temp_env();
4733        let cfg_path = env.codex_home.join("config.toml");
4734        let auth_path = env.codex_home.join("auth.json");
4735
4736        write_file(
4737            &cfg_path,
4738            r#"
4739model_provider = "openai"
4740
4741[model_providers.openai]
4742name = "openai"
4743base_url = "https://api.openai.com/v1"
4744"#
4745            .trim_start(),
4746        );
4747        write_file(
4748            &auth_path,
4749            &chatgpt_bridge_auth_json_text(&chatgpt_auth_json(
4750                "user@example.com",
4751                "acct_1",
4752                "plus",
4753            ))
4754            .expect("pre-patch auth fixture"),
4755        );
4756        let before = std::fs::metadata(&auth_path)
4757            .expect("auth metadata")
4758            .modified()
4759            .expect("auth modified time");
4760
4761        switch_on_with_mode(3211, CodexPatchMode::ChatGptBridge)
4762            .expect("switch_on bridge should patch config without rewriting already-patched auth");
4763
4764        let after = std::fs::metadata(&auth_path)
4765            .expect("auth metadata")
4766            .modified()
4767            .expect("auth modified time");
4768        assert_eq!(before, after);
4769    }
4770
4771    #[test]
4772    fn codex_switch_on_chatgpt_bridge_refuses_incomplete_auth_without_writing_config() {
4773        let env = setup_temp_env();
4774        let cfg_path = env.codex_home.join("config.toml");
4775        let auth_path = env.codex_home.join("auth.json");
4776        let state_path = env.codex_home.join("codex-helper-switch-state.json");
4777        let original_config = r#"
4778model_provider = "openai"
4779
4780[model_providers.openai]
4781name = "openai"
4782base_url = "https://api.openai.com/v1"
4783"#
4784        .trim_start();
4785        let original_auth = r#"{"auth_mode":"chatgpt","OPENAI_API_KEY":null}"#;
4786
4787        write_file(&cfg_path, original_config);
4788        write_file(&auth_path, original_auth);
4789
4790        let err = switch_on_with_mode(3211, CodexPatchMode::ChatGptBridge)
4791            .expect_err("incomplete ChatGPT auth should be rejected before writing config");
4792
4793        assert!(err.to_string().contains("complete ChatGPT login state"));
4794        assert_eq!(read_file(&cfg_path), original_config);
4795        assert_eq!(read_file(&auth_path), original_auth);
4796        assert!(
4797            !state_path.exists(),
4798            "failed bridge switch must not create switch state"
4799        );
4800    }
4801
4802    #[test]
4803    fn codex_switch_on_refuses_local_proxy_without_switch_state() {
4804        let env = setup_temp_env();
4805        let cfg_path = env.codex_home.join("config.toml");
4806        let state_path = env.codex_home.join("codex-helper-switch-state.json");
4807
4808        write_file(
4809            &cfg_path,
4810            r#"
4811model_provider = "codex_proxy"
4812
4813[model_providers.codex_proxy]
4814name = "codex-helper"
4815base_url = "http://127.0.0.1:3211"
4816"#
4817            .trim_start(),
4818        );
4819
4820        let err = switch_on(3211).expect_err("switch_on should not snapshot a local proxy");
4821        assert!(err.to_string().contains("no switch state was found"));
4822        assert!(
4823            !state_path.exists(),
4824            "switch_on must not create state from an already-patched local proxy"
4825        );
4826    }
4827
4828    #[test]
4829    fn codex_config_text_for_import_hides_proxy_created_from_absent_config() {
4830        let env = setup_temp_env();
4831        let cfg_path = env.codex_home.join("config.toml");
4832
4833        switch_on(3211).expect("switch_on should create config");
4834        assert!(cfg_path.exists());
4835
4836        let import_text = codex_config_text_for_import()
4837            .expect("read import view")
4838            .expect("config exists");
4839        assert!(
4840            import_text.trim().is_empty(),
4841            "import view should not expose helper proxy as a real upstream"
4842        );
4843    }
4844
4845    #[test]
4846    fn codex_switch_off_clears_switch_state_and_refreshes_next_snapshot() {
4847        let env = setup_temp_env();
4848        let cfg_path = env.codex_home.join("config.toml");
4849        let state_path = env.codex_home.join("codex-helper-switch-state.json");
4850
4851        let original = r#"
4852model_provider = "openai"
4853
4854[model_providers.openai]
4855name = "openai"
4856base_url = "https://api.openai.com/v1"
4857"#;
4858        let updated = r#"
4859model_provider = "packycode"
4860
4861[model_providers.packycode]
4862name = "packycode"
4863base_url = "https://codex-api.packycode.com/v1"
4864"#;
4865
4866        write_file(&cfg_path, original.trim_start());
4867        switch_on(3211).expect("first switch_on should succeed");
4868        assert!(
4869            state_path.exists(),
4870            "switch state should exist while patched"
4871        );
4872        let state_text = read_file(&state_path);
4873        assert!(state_text.contains("\"original_model_provider\": \"openai\""));
4874        assert!(
4875            !state_text.contains("api.openai.com"),
4876            "switch state should not store the full Codex config"
4877        );
4878
4879        switch_off().expect("first switch_off should succeed");
4880        assert_eq!(read_file(&cfg_path), original.trim_start());
4881        assert!(
4882            !state_path.exists(),
4883            "switch state should be removed after patch-off to avoid stale snapshots"
4884        );
4885
4886        write_file(&cfg_path, updated.trim_start());
4887        switch_on(3211).expect("second switch_on should succeed");
4888        let state_text = read_file(&state_path);
4889        assert!(state_text.contains("\"original_model_provider\": \"packycode\""));
4890
4891        switch_off().expect("second switch_off should succeed");
4892        assert_eq!(read_file(&cfg_path), updated.trim_start());
4893        assert!(
4894            !state_path.exists(),
4895            "switch state should be cleaned up after the second patch-off as well"
4896        );
4897    }
4898
4899    #[test]
4900    fn codex_switch_off_preserves_codex_runtime_config_edits() {
4901        let env = setup_temp_env();
4902        let cfg_path = env.codex_home.join("config.toml");
4903        let state_path = env.codex_home.join("codex-helper-switch-state.json");
4904
4905        let original = r#"
4906model_provider = "openai"
4907
4908[model_providers.openai]
4909name = "openai"
4910base_url = "https://api.openai.com/v1"
4911"#;
4912
4913        write_file(&cfg_path, original.trim_start());
4914        switch_on(3211).expect("switch_on should succeed");
4915
4916        let mut during_run = read_file(&cfg_path);
4917        during_run.push_str(
4918            r#"
4919[projects."D:\\Projects\\rust\\codex-helper"]
4920trust_level = "trusted"
4921"#,
4922        );
4923        write_file(&cfg_path, &during_run);
4924
4925        switch_off().expect("switch_off should patch rather than restore whole file");
4926
4927        let updated = read_file(&cfg_path);
4928        assert!(updated.contains("model_provider = \"openai\""));
4929        assert!(updated.contains("[model_providers.openai]"));
4930        assert!(!updated.contains("[model_providers.codex_proxy]"));
4931        assert!(updated.contains("[projects."));
4932        assert!(updated.contains("trust_level = \"trusted\""));
4933        assert!(
4934            !state_path.exists(),
4935            "switch state should be removed after successful patch-off"
4936        );
4937    }
4938
4939    #[test]
4940    fn codex_switch_off_keeps_user_provider_change_made_during_run() {
4941        let env = setup_temp_env();
4942        let cfg_path = env.codex_home.join("config.toml");
4943
4944        let original = r#"
4945model_provider = "openai"
4946
4947[model_providers.openai]
4948name = "openai"
4949base_url = "https://api.openai.com/v1"
4950"#;
4951        let user_changed = r#"
4952model_provider = "packycode"
4953
4954[model_providers.openai]
4955name = "openai"
4956base_url = "https://api.openai.com/v1"
4957
4958[model_providers.codex_proxy]
4959name = "codex-helper"
4960base_url = "http://127.0.0.1:3211"
4961wire_api = "responses"
4962request_max_retries = 0
4963
4964[model_providers.packycode]
4965name = "packycode"
4966base_url = "https://codex-api.packycode.com/v1"
4967"#;
4968
4969        write_file(&cfg_path, original.trim_start());
4970        switch_on(3211).expect("switch_on should succeed");
4971        write_file(&cfg_path, user_changed.trim_start());
4972
4973        switch_off().expect("switch_off should not undo user's model_provider change");
4974
4975        let updated = read_file(&cfg_path);
4976        assert!(updated.contains("model_provider = \"packycode\""));
4977        assert!(updated.contains("[model_providers.packycode]"));
4978        assert!(!updated.contains("[model_providers.codex_proxy]"));
4979    }
4980
4981    #[test]
4982    fn codex_switch_off_preserves_new_config_when_original_was_absent() {
4983        let env = setup_temp_env();
4984        let cfg_path = env.codex_home.join("config.toml");
4985
4986        switch_on(3211).expect("switch_on should create config");
4987        let mut during_run = read_file(&cfg_path);
4988        during_run.push_str(
4989            r#"
4990[projects."D:\\Projects\\rust\\codex-helper"]
4991trust_level = "trusted"
4992"#,
4993        );
4994        write_file(&cfg_path, &during_run);
4995
4996        switch_off().expect("switch_off should remove only local proxy fields");
4997
4998        let updated = read_file(&cfg_path);
4999        assert!(!updated.contains("model_provider = \"codex_proxy\""));
5000        assert!(!updated.contains("[model_providers.codex_proxy]"));
5001        assert!(updated.contains("[projects."));
5002        assert!(updated.contains("trust_level = \"trusted\""));
5003    }
5004
5005    #[test]
5006    fn codex_switch_off_removes_empty_config_created_by_switch_on() {
5007        let env = setup_temp_env();
5008        let cfg_path = env.codex_home.join("config.toml");
5009
5010        switch_on(3211).expect("switch_on should create config");
5011        assert!(cfg_path.exists());
5012
5013        switch_off().expect("switch_off should restore absent config state");
5014
5015        assert!(
5016            !cfg_path.exists(),
5017            "config created only for the local proxy should be removed"
5018        );
5019    }
5020
5021    #[test]
5022    fn claude_switch_off_clears_backup_and_refreshes_next_snapshot() {
5023        let env = setup_temp_env();
5024        let settings_path = env.claude_home.join("settings.json");
5025        let backup_path = env.claude_home.join("settings.json.codex-helper-backup");
5026
5027        let original = r#"{
5028  "env": {
5029    "ANTHROPIC_BASE_URL": "https://api.anthropic.com/v1",
5030    "ANTHROPIC_API_KEY": "sk-ant-1"
5031  }
5032}"#;
5033        let updated = r#"{
5034  "env": {
5035    "ANTHROPIC_BASE_URL": "https://anthropic-proxy.example/v1",
5036    "ANTHROPIC_API_KEY": "sk-ant-2"
5037  }
5038}"#;
5039
5040        write_file(&settings_path, original);
5041        claude_switch_on(3211).expect("first claude_switch_on should succeed");
5042        assert!(
5043            backup_path.exists(),
5044            "backup should exist while switched on"
5045        );
5046
5047        claude_switch_off().expect("first claude_switch_off should succeed");
5048        assert_eq!(read_file(&settings_path), original);
5049        assert!(
5050            !backup_path.exists(),
5051            "backup should be removed after Claude restore to avoid stale snapshots"
5052        );
5053
5054        write_file(&settings_path, updated);
5055        claude_switch_on(3211).expect("second claude_switch_on should succeed");
5056        assert_eq!(read_file(&backup_path), updated);
5057
5058        claude_switch_off().expect("second claude_switch_off should succeed");
5059        assert_eq!(read_file(&settings_path), updated);
5060        assert!(
5061            !backup_path.exists(),
5062            "backup should be cleaned up after the second Claude restore as well"
5063        );
5064    }
5065}
5066
5067/// Warn before replacing an existing local Claude proxy patch.
5068pub fn guard_claude_settings_before_switch_on_interactive() -> Result<()> {
5069    use std::io::{self, Write};
5070
5071    let settings_path = claude_settings_path();
5072    if !settings_path.exists() {
5073        return Ok(());
5074    }
5075    let backup_path = claude_settings_backup_path(&settings_path);
5076
5077    let text = read_settings_text(&settings_path)?;
5078    if text.trim().is_empty() {
5079        return Ok(());
5080    }
5081
5082    let value: serde_json::Value = match serde_json::from_str(&text) {
5083        Ok(value) => value,
5084        Err(_) => return Ok(()),
5085    };
5086    let obj = match value.as_object() {
5087        Some(obj) => obj,
5088        None => return Ok(()),
5089    };
5090    let env_obj = match obj.get("env").and_then(|value| value.as_object()) {
5091        Some(env_obj) => env_obj,
5092        None => return Ok(()),
5093    };
5094
5095    let base_url = env_obj
5096        .get("ANTHROPIC_BASE_URL")
5097        .and_then(|value| value.as_str())
5098        .unwrap_or_default();
5099
5100    let is_local = base_url.contains("127.0.0.1") || base_url.contains("localhost");
5101    if !is_local {
5102        return Ok(());
5103    }
5104
5105    if !backup_path.exists() {
5106        eprintln!(
5107            "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.",
5108            settings_path, backup_path
5109        );
5110        return Ok(());
5111    }
5112
5113    let is_tty = atty::is(atty::Stream::Stdin) && atty::is(atty::Stream::Stdout);
5114    if !is_tty {
5115        eprintln!(
5116            "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.",
5117            settings_path, backup_path
5118        );
5119        return Ok(());
5120    }
5121
5122    eprintln!(
5123        "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] ",
5124        settings_path, backup_path
5125    );
5126    eprint!("> ");
5127    io::stdout().flush().ok();
5128
5129    let mut input = String::new();
5130    if let Err(err) = io::stdin().read_line(&mut input) {
5131        eprintln!("Failed to read input: {err}");
5132        return Ok(());
5133    }
5134    let answer = input.trim();
5135    let yes =
5136        answer.is_empty() || answer.eq_ignore_ascii_case("y") || answer.eq_ignore_ascii_case("yes");
5137
5138    if yes {
5139        if let Err(err) = claude_switch_off() {
5140            eprintln!("Failed to restore Claude settings: {err}");
5141        } else {
5142            eprintln!("Restored Claude settings from backup.");
5143        }
5144    } else {
5145        eprintln!("Keeping current Claude settings unchanged.");
5146    }
5147
5148    Ok(())
5149}