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