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