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(¤t_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 pub enabled: bool,
1015 pub model_provider: Option<String>,
1017 pub provider_name: Option<String>,
1019 pub base_url: Option<String>,
1021 pub patch_mode: Option<CodexPatchMode>,
1023 pub requires_openai_auth: Option<bool>,
1025 pub supports_websockets: Option<bool>,
1027 pub remote_compaction_v2_enabled: bool,
1029 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
1908pub fn switch_on(port: u16) -> Result<()> {
1910 switch_on_with_mode(port, CodexPatchMode::Default)
1911}
1912
1913pub 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
1921pub fn switch_on_with_mode(port: u16, mode: CodexPatchMode) -> Result<()> {
1923 switch_on_with_options(port, mode, CodexSwitchOptions::default())
1924}
1925
1926pub 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
1936pub 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
1977pub 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(¤t_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 pub enabled: bool,
2387 pub base_url: Option<String>,
2389 pub has_backup: bool,
2391 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
2452pub 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 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
5092pub 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}