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