@@ -1394,7 +1394,7 @@ fn remove_legacy_vscode_base_url_keys() -> Result<(Vec<String>, Vec<String>)> {
13941394}
13951395
13961396fn codex_config_toml_path ( ) -> PathBuf {
1397- home_dir ( ) . join ( ".codex" ) . join ( "config.toml" )
1397+ codex_home ( ) . join ( "config.toml" )
13981398}
13991399
14001400// The managed Codex config is split across two marker blocks so each lands in
@@ -1423,14 +1423,50 @@ const CODEX_TABLE_BLOCK_ID: &str = "codex_cli_provider";
14231423const CODEX_HEADROOM_PROVIDER : & str = "headroom" ;
14241424const CODEX_NATIVE_PROVIDER : & str = "openai" ;
14251425
1426- /// Both known Codex state stores: the v148 GUI reads
1427- /// `~/.codex/sqlite/state_5.sqlite`, the CLI/TUI uses `~/.codex/state_5.sqlite`.
1428- fn codex_state_db_paths ( ) -> Vec < PathBuf > {
1429- let codex = home_dir ( ) . join ( ".codex" ) ;
1430- vec ! [
1431- codex. join( "sqlite" ) . join( "state_5.sqlite" ) ,
1432- codex. join( "state_5.sqlite" ) ,
1433- ]
1426+ /// Codex store-schema versions this build has been verified against. Discovered
1427+ /// stores with a version outside this set are still retagged (best-effort) but
1428+ /// logged, so a Codex store bump is visible before it can silently split the
1429+ /// history menu for everyone.
1430+ const KNOWN_CODEX_STORE_VERSIONS : & [ u32 ] = & [ 5 ] ;
1431+
1432+ /// Directories Codex is known to keep its state store in: the v148 GUI uses
1433+ /// `<codex_home>/sqlite/`, the CLI/TUI uses `<codex_home>/`.
1434+ fn codex_state_dirs ( ) -> Vec < PathBuf > {
1435+ let codex = codex_home ( ) ;
1436+ vec ! [ codex. join( "sqlite" ) , codex]
1437+ }
1438+
1439+ /// Parse `N` from a `state_<N>.sqlite` filename (`state_5.sqlite` -> `Some(5)`).
1440+ /// Anything else -> `None`.
1441+ fn codex_store_version ( path : & Path ) -> Option < u32 > {
1442+ let name = path. file_name ( ) ?. to_str ( ) ?;
1443+ name. strip_prefix ( "state_" ) ?. strip_suffix ( ".sqlite" ) ?. parse ( ) . ok ( )
1444+ }
1445+
1446+ /// Discover every `state_<N>.sqlite` store under the known Codex dirs, with the
1447+ /// version parsed from its name. Scanning the directories (rather than probing a
1448+ /// hardcoded `state_5.sqlite`) means a future Codex store-version bump keeps
1449+ /// working without a release instead of silently no-opping for every user at
1450+ /// once. A missing dir (`read_dir` error) is skipped. Paths are deduped in case
1451+ /// the two dirs ever resolve to the same place.
1452+ fn discover_codex_state_dbs ( ) -> Vec < ( PathBuf , u32 ) > {
1453+ let mut out = Vec :: new ( ) ;
1454+ let mut seen = BTreeSet :: new ( ) ;
1455+ for dir in codex_state_dirs ( ) {
1456+ let Ok ( entries) = std:: fs:: read_dir ( & dir) else {
1457+ continue ;
1458+ } ;
1459+ for entry in entries. flatten ( ) {
1460+ let path = entry. path ( ) ;
1461+ let Some ( version) = codex_store_version ( & path) else {
1462+ continue ;
1463+ } ;
1464+ if seen. insert ( path. clone ( ) ) {
1465+ out. push ( ( path, version) ) ;
1466+ }
1467+ }
1468+ }
1469+ out
14341470}
14351471
14361472/// Best-effort retag of Codex thread provider tags so the history menu stays
@@ -1439,9 +1475,30 @@ fn codex_state_db_paths() -> Vec<PathBuf> {
14391475/// and skipped. Only rows whose `model_provider` equals `from` are touched, so
14401476/// third-party providers are left alone.
14411477fn retag_codex_thread_providers ( from : & str , to : & str ) {
1442- for path in codex_state_db_paths ( ) {
1443- if !path. exists ( ) {
1444- continue ;
1478+ let stores = discover_codex_state_dbs ( ) ;
1479+ if stores. is_empty ( ) {
1480+ // Only a signal when Codex is actually present: the launch/quit
1481+ // lifecycle hooks call this for every user, so a clean machine with no
1482+ // Codex install must stay silent.
1483+ if codex_user_state_exists ( ) {
1484+ log:: warn!(
1485+ "codex retag {from}->{to}: Codex is present but no state_<N>.sqlite \
1486+ store was found under {dirs:?}; the history menu may split. Codex \
1487+ may have moved or renamed its store.",
1488+ dirs = codex_state_dirs( ) ,
1489+ ) ;
1490+ }
1491+ return ;
1492+ }
1493+ for ( path, version) in stores {
1494+ if !KNOWN_CODEX_STORE_VERSIONS . contains ( & version) {
1495+ log:: warn!(
1496+ "codex retag: store version {version} at {} is outside the known \
1497+ set {KNOWN_CODEX_STORE_VERSIONS:?}; retagging anyway. Verify the \
1498+ history menu still works and add {version} to \
1499+ KNOWN_CODEX_STORE_VERSIONS.",
1500+ path. display( ) ,
1501+ ) ;
14451502 }
14461503 match retag_one_codex_db ( & path, from, to) {
14471504 Ok ( 0 ) => { }
@@ -1507,7 +1564,7 @@ fn codex_root_keys_body() -> String {
15071564/// key), read from `~/.codex/auth.json`. Drives whether the managed provider
15081565/// block carries `requires_openai_auth = true` (see [`codex_provider_table_body`]).
15091566fn codex_uses_chatgpt_auth ( ) -> bool {
1510- let path = home_dir ( ) . join ( ".codex" ) . join ( "auth.json" ) ;
1567+ let path = codex_home ( ) . join ( "auth.json" ) ;
15111568 let Ok ( raw) = std:: fs:: read_to_string ( & path) else {
15121569 return false ;
15131570 } ;
@@ -2258,6 +2315,17 @@ fn home_dir() -> PathBuf {
22582315 . unwrap_or_else ( || std:: env:: temp_dir ( ) )
22592316}
22602317
2318+ /// Codex's home directory. Mirrors the Codex CLI and the upstream Headroom
2319+ /// proxy: honor `$CODEX_HOME` when set, else `~/.codex`. Staying in sync with
2320+ /// the proxy matters — if the two layers disagree on where Codex lives, the
2321+ /// provider retag rewrites a different store than the config it edited.
2322+ fn codex_home ( ) -> PathBuf {
2323+ std:: env:: var_os ( "CODEX_HOME" )
2324+ . filter ( |v| !v. is_empty ( ) )
2325+ . map ( PathBuf :: from)
2326+ . unwrap_or_else ( || home_dir ( ) . join ( ".codex" ) )
2327+ }
2328+
22612329fn detect_claude_code_client ( configured : bool ) -> ClientStatus {
22622330 let executable = claude_code_candidate_paths ( )
22632331 . into_iter ( )
@@ -2410,7 +2478,8 @@ fn detect_codex_client(configured: bool) -> ClientStatus {
24102478 . as_ref ( )
24112479 . map ( |path| format ! ( "Detected at {}" , path. display( ) ) )
24122480 . or_else ( || {
2413- codex_user_state_exists ( & home_dir ( ) ) . then ( || "Detected Codex data in ~/.codex." . into ( ) )
2481+ codex_user_state_exists ( )
2482+ . then ( || format ! ( "Detected Codex data in {}." , codex_home( ) . display( ) ) )
24142483 } ) ;
24152484
24162485 if let Some ( detected_note) = detected {
@@ -2471,8 +2540,8 @@ fn codex_candidate_paths() -> Vec<PathBuf> {
24712540 dedupe_paths ( candidates)
24722541}
24732542
2474- fn codex_user_state_exists ( home : & Path ) -> bool {
2475- let codex_root = home . join ( ".codex" ) ;
2543+ fn codex_user_state_exists ( ) -> bool {
2544+ let codex_root = codex_home ( ) ;
24762545 codex_root. join ( "config.toml" ) . exists ( )
24772546 || codex_root. join ( "auth.json" ) . exists ( )
24782547 || codex_root. join ( "sessions" ) . exists ( )
@@ -2492,7 +2561,7 @@ pub(crate) fn detect_codex_cli() -> Option<PathBuf> {
24922561/// OAuth token lands in `~/.codex/auth.json`. Required for the keyless
24932562/// `codex exec` analysis backend.
24942563pub ( crate ) fn codex_logged_in ( ) -> bool {
2495- home_dir ( ) . join ( ".codex" ) . join ( "auth.json" ) . is_file ( )
2564+ codex_home ( ) . join ( "auth.json" ) . is_file ( )
24962565}
24972566
24982567fn parse_json_object ( raw : & str , path : & Path ) -> Result < serde_json:: Map < String , Value > > {
@@ -2559,7 +2628,7 @@ fn windows_path_extensions() -> Vec<String> {
25592628
25602629#[ cfg( test) ]
25612630mod tests {
2562- use std:: collections:: BTreeMap ;
2631+ use std:: collections:: { BTreeMap , BTreeSet } ;
25632632 use std:: fs;
25642633 use std:: path:: { Path , PathBuf } ;
25652634 use std:: time:: { SystemTime , UNIX_EPOCH } ;
@@ -2570,8 +2639,9 @@ mod tests {
25702639 build_headroom_rtk_hook, claude_code_user_state_exists, claude_hook_present_in_value,
25712640 default_shell_targets_for_family, entry_contains_hook, find_on_path_entries,
25722641 normalize_setup_state, normalized_setup_id, nvm_binary_candidates, parse_json_object,
2573- remove_managed_block, retag_codex_thread_providers, retag_codex_threads_to_headroom,
2574- retag_one_codex_db, serialize_paths, shell_block_contains_in_files,
2642+ codex_home, codex_store_version, discover_codex_state_dbs, remove_managed_block,
2643+ retag_codex_thread_providers, retag_codex_threads_to_headroom, retag_one_codex_db,
2644+ serialize_paths, shell_block_contains_in_files,
25752645 shell_block_contains_text_in_files, shell_double_quote, strip_headroom_hook_from_settings,
25762646 upsert_managed_block, write_file_if_changed, ClientSetupState , ShellFamily ,
25772647 } ;
@@ -3358,6 +3428,7 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:6767
33583428 prev_home : Option < std:: ffi:: OsString > ,
33593429 prev_xdg : Option < std:: ffi:: OsString > ,
33603430 prev_shell : Option < std:: ffi:: OsString > ,
3431+ prev_codex : Option < std:: ffi:: OsString > ,
33613432 }
33623433
33633434 impl TestHome {
@@ -3367,11 +3438,15 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:6767
33673438 let prev_home = std:: env:: var_os ( "HOME" ) ;
33683439 let prev_xdg = std:: env:: var_os ( "XDG_DATA_HOME" ) ;
33693440 let prev_shell = std:: env:: var_os ( "SHELL" ) ;
3441+ let prev_codex = std:: env:: var_os ( "CODEX_HOME" ) ;
33703442 std:: env:: set_var ( "HOME" , & home) ;
33713443 std:: env:: set_var ( "XDG_DATA_HOME" , home. join ( ".local" ) . join ( "share" ) ) ;
33723444 // Force a deterministic shell family so tests don't depend on the
33733445 // dev's login shell.
33743446 std:: env:: set_var ( "SHELL" , "/bin/zsh" ) ;
3447+ // Clear any real CODEX_HOME so codex_home() falls back to the temp
3448+ // $HOME/.codex and the Codex tests stay hermetic on dev machines.
3449+ std:: env:: remove_var ( "CODEX_HOME" ) ;
33753450 // Mirror what the app does at startup so write_setup_state has a
33763451 // config dir to land in.
33773452 crate :: storage:: ensure_data_dirs ( & crate :: storage:: app_data_dir ( ) )
@@ -3382,6 +3457,7 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:6767
33823457 prev_home,
33833458 prev_xdg,
33843459 prev_shell,
3460+ prev_codex,
33853461 }
33863462 }
33873463
@@ -3404,6 +3480,10 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:6767
34043480 Some ( v) => std:: env:: set_var ( "SHELL" , v) ,
34053481 None => std:: env:: remove_var ( "SHELL" ) ,
34063482 }
3483+ match self . prev_codex . take ( ) {
3484+ Some ( v) => std:: env:: set_var ( "CODEX_HOME" , v) ,
3485+ None => std:: env:: remove_var ( "CODEX_HOME" ) ,
3486+ }
34073487 }
34083488 }
34093489
@@ -4079,4 +4159,66 @@ export ANTHROPIC_BASE_URL=http://127.0.0.1:6767
40794159 // Third-party threads are untouched.
40804160 assert_eq ! ( provider_count( & db, "anthropic" ) , 1 ) ;
40814161 }
4162+
4163+ #[ test]
4164+ fn codex_store_version_parses_state_filename ( ) {
4165+ assert_eq ! ( codex_store_version( Path :: new( "/x/state_5.sqlite" ) ) , Some ( 5 ) ) ;
4166+ assert_eq ! ( codex_store_version( Path :: new( "/x/state_42.sqlite" ) ) , Some ( 42 ) ) ;
4167+ assert_eq ! ( codex_store_version( Path :: new( "/x/config.toml" ) ) , None ) ;
4168+ assert_eq ! ( codex_store_version( Path :: new( "/x/state_.sqlite" ) ) , None ) ;
4169+ assert_eq ! ( codex_store_version( Path :: new( "/x/state_x.sqlite" ) ) , None ) ;
4170+ assert_eq ! ( codex_store_version( Path :: new( "/x/state_5.db" ) ) , None ) ;
4171+ }
4172+
4173+ #[ test]
4174+ #[ serial_test:: serial]
4175+ fn codex_home_honors_env_else_default ( ) {
4176+ let home = TestHome :: new ( ) ;
4177+ // TestHome clears CODEX_HOME, so we fall back to $HOME/.codex.
4178+ assert_eq ! ( codex_home( ) , home. path( ) . join( ".codex" ) ) ;
4179+
4180+ let custom = home. path ( ) . join ( "custom-codex" ) ;
4181+ std:: env:: set_var ( "CODEX_HOME" , & custom) ;
4182+ assert_eq ! ( codex_home( ) , custom) ;
4183+
4184+ // An empty value is ignored (treated as unset).
4185+ std:: env:: set_var ( "CODEX_HOME" , "" ) ;
4186+ assert_eq ! ( codex_home( ) , home. path( ) . join( ".codex" ) ) ;
4187+ }
4188+
4189+ #[ test]
4190+ #[ serial_test:: serial]
4191+ fn discover_codex_state_dbs_finds_versioned_stores ( ) {
4192+ let home = TestHome :: new ( ) ;
4193+ let codex = home. path ( ) . join ( ".codex" ) ;
4194+ std:: fs:: create_dir_all ( codex. join ( "sqlite" ) ) . unwrap ( ) ;
4195+ // GUI store under sqlite/, CLI store at the root, on different versions.
4196+ std:: fs:: File :: create ( codex. join ( "sqlite" ) . join ( "state_6.sqlite" ) ) . unwrap ( ) ;
4197+ std:: fs:: File :: create ( codex. join ( "state_5.sqlite" ) ) . unwrap ( ) ;
4198+ // A non-store file in the same dir must be ignored.
4199+ std:: fs:: File :: create ( codex. join ( "config.toml" ) ) . unwrap ( ) ;
4200+
4201+ let versions: BTreeSet < u32 > = discover_codex_state_dbs ( )
4202+ . into_iter ( )
4203+ . map ( |( _, v) | v)
4204+ . collect ( ) ;
4205+ assert_eq ! ( versions, BTreeSet :: from( [ 5 , 6 ] ) ) ;
4206+ }
4207+
4208+ #[ test]
4209+ #[ serial_test:: serial]
4210+ fn retag_handles_unknown_store_version ( ) {
4211+ // Future-proofing: a Codex store-version bump (here state_99) must still
4212+ // retag, not silently no-op for every user at once.
4213+ let home = TestHome :: new ( ) ;
4214+ let db = home. path ( ) . join ( ".codex" ) . join ( "state_99.sqlite" ) ;
4215+ std:: fs:: create_dir_all ( db. parent ( ) . unwrap ( ) ) . unwrap ( ) ;
4216+ seed_codex_threads_db ( & db, & [ ( "a" , "openai" ) , ( "b" , "openai" ) , ( "c" , "anthropic" ) ] ) ;
4217+
4218+ retag_codex_threads_to_headroom ( ) ;
4219+
4220+ assert_eq ! ( provider_count( & db, "headroom" ) , 2 ) ;
4221+ assert_eq ! ( provider_count( & db, "openai" ) , 0 ) ;
4222+ assert_eq ! ( provider_count( & db, "anthropic" ) , 1 ) ;
4223+ }
40824224}
0 commit comments