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