Compare commits

...

1 Commits

Author SHA1 Message Date
saladday
687a73a88f Allow Codex to recover from stored auth when auth.json is missing
Codex provider switch and live-state reads currently stop relying on disk auth as the only recoverable source. This change keeps the existing disk-first behavior, but when ~/.codex/auth.json is absent it falls back to the current provider's stored auth so switch backfill and live reads can still succeed.

The fix is threaded through provider switch backfill, the Tauri command that reads live provider settings, and startup common-config extraction. Two regression tests cover the missing-auth.json switch path and direct live-read path.

Constraint: Keep current behavior unchanged when auth.json exists
Rejected: Make Codex fully DB-first for auth reads | broader semantic change than needed for this bug
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: If Codex auth source precedence changes later, update both switch-backfill and read_live_settings fallback paths together
Tested: cargo test --manifest-path src-tauri/Cargo.toml; cargo test --manifest-path src-tauri/Cargo.toml --test provider_service -- --nocapture; cargo test --manifest-path src-tauri/Cargo.toml --test provider_commands -- --nocapture; cargo fmt --manifest-path src-tauri/Cargo.toml --check
Not-tested: pnpm typecheck / format:check (node_modules missing in local checkout); cargo clippy --all-targets -D warnings currently fails on pre-existing unrelated warnings in commands/settings.rs, commands/misc.rs, database/backup.rs, provider.rs, proxy/response_processor.rs
2026-04-05 23:38:35 +08:00
5 changed files with 207 additions and 24 deletions

View File

@@ -307,9 +307,12 @@ pub async fn testUsageScript(
}
#[tauri::command]
pub fn read_live_provider_settings(app: String) -> Result<serde_json::Value, String> {
pub fn read_live_provider_settings(
state: State<'_, AppState>,
app: String,
) -> Result<serde_json::Value, String> {
let app_type = AppType::from_str(&app).map_err(|e| e.to_string())?;
ProviderService::read_live_settings(app_type).map_err(|e| e.to_string())
ProviderService::read_live_settings(&state, app_type).map_err(|e| e.to_string())
}
#[tauri::command]

View File

@@ -1348,6 +1348,7 @@ fn initialize_common_config_snippets(state: &store::AppState) {
}
let settings = match crate::services::provider::ProviderService::read_live_settings(
state,
app_type.clone(),
) {
Ok(s) => s,

View File

@@ -851,6 +851,26 @@ pub(crate) fn sync_current_provider_for_app_to_live(
Ok(())
}
fn read_codex_live_settings_with_auth_fallback(
fallback_auth: Option<Value>,
) -> Result<Value, AppError> {
let auth_path = get_codex_auth_path();
let auth = if auth_path.exists() {
read_json_file(&auth_path)?
} else if let Some(auth) = fallback_auth {
auth
} else {
return Err(AppError::localized(
"codex.auth.missing",
"Codex 配置文件不存在:缺少 auth.json",
"Codex configuration missing: auth.json not found",
));
};
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
Ok(json!({ "auth": auth, "config": cfg_text }))
}
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID验证过存在性
@@ -895,22 +915,20 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
Ok(())
}
pub(crate) fn read_live_settings_with_auth_fallback(
app_type: AppType,
fallback_auth: Option<Value>,
) -> Result<Value, AppError> {
match app_type {
AppType::Codex => read_codex_live_settings_with_auth_fallback(fallback_auth),
_ => read_live_settings(app_type),
}
}
/// Read current live settings for an app type
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
match app_type {
AppType::Codex => {
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Err(AppError::localized(
"codex.auth.missing",
"Codex 配置文件不存在:缺少 auth.json",
"Codex configuration missing: auth.json not found",
));
}
let auth: Value = read_json_file(&auth_path)?;
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
Ok(json!({ "auth": auth, "config": cfg_text }))
}
AppType::Codex => read_codex_live_settings_with_auth_fallback(None),
AppType::Claude => {
let path = get_claude_settings_path();
if !path.exists() {

View File

@@ -22,15 +22,16 @@ use crate::store::AppState;
// Re-export sub-module functions for external access
pub use live::{
import_default_config, import_openclaw_providers_from_live,
import_opencode_providers_from_live, read_live_settings, sync_current_to_live,
import_opencode_providers_from_live, sync_current_to_live,
};
// Internal re-exports (pub(crate))
pub(crate) use live::sanitize_claude_settings_for_live;
pub(crate) use live::{
build_effective_settings_with_common_config, normalize_provider_common_config_for_storage,
provider_exists_in_live_config, strip_common_config_from_live_settings,
sync_current_provider_for_app_to_live, write_live_with_common_config,
provider_exists_in_live_config, read_live_settings_with_auth_fallback,
strip_common_config_from_live_settings, sync_current_provider_for_app_to_live,
write_live_with_common_config,
};
// Internal re-exports
@@ -1473,8 +1474,16 @@ impl ProviderService {
// no backfill needed (backfill is for exclusive mode apps like Claude/Codex/Gemini)
if !app_type.is_additive_mode() {
// Only backfill when switching to a different provider
if let Ok(live_config) = read_live_settings(app_type.clone()) {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
if let Some(mut current_provider) = providers.get(&current_id).cloned() {
let fallback_auth = if matches!(app_type, AppType::Codex) {
current_provider.settings_config.get("auth").cloned()
} else {
None
};
if let Ok(live_config) =
read_live_settings_with_auth_fallback(app_type.clone(), fallback_auth)
{
current_provider.settings_config =
strip_common_config_from_live_settings(
state.db.as_ref(),
@@ -1897,8 +1906,21 @@ impl ProviderService {
}
/// Read current live settings (re-export)
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
read_live_settings(app_type)
pub fn read_live_settings(state: &AppState, app_type: AppType) -> Result<Value, AppError> {
let fallback_auth = if matches!(app_type, AppType::Codex) {
let current_id = crate::settings::get_effective_current_provider(&state.db, &app_type)?;
match current_id {
Some(current_id) => state
.db
.get_provider_by_id(&current_id, app_type.as_str())?
.and_then(|provider| provider.settings_config.get("auth").cloned()),
None => None,
}
} else {
None
};
read_live_settings_with_auth_fallback(app_type, fallback_auth)
}
/// Get custom endpoints list (re-export)

View File

@@ -1,8 +1,8 @@
use serde_json::json;
use cc_switch_lib::{
get_claude_settings_path, read_json_file, write_codex_live_atomic, AppError, AppType, McpApps,
McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
get_claude_settings_path, get_codex_config_path, read_json_file, write_codex_live_atomic,
AppError, AppType, McpApps, McpServer, MultiAppConfig, Provider, ProviderMeta, ProviderService,
};
#[path = "support.rs"]
@@ -238,6 +238,145 @@ command = "say"
);
}
#[test]
fn provider_service_switch_codex_backfills_current_provider_when_auth_json_missing() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let live_config = r#"[mcp_servers.legacy]
type = "stdio"
command = "echo"
"#;
let config_path = get_codex_config_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).expect("create codex dir");
}
std::fs::write(&config_path, live_config).expect("seed codex config without auth.json");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "old-provider".to_string();
manager.providers.insert(
"old-provider".to_string(),
Provider::with_id(
"old-provider".to_string(),
"Legacy".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "db-key"},
"config": "stale-config"
}),
None,
),
);
manager.providers.insert(
"new-provider".to_string(),
Provider::with_id(
"new-provider".to_string(),
"Latest".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "fresh-key"},
"config": r#"[mcp_servers.latest]
type = "stdio"
command = "say"
"#
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
ProviderService::switch(&state, AppType::Codex, "new-provider")
.expect("switch provider should succeed without auth.json");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("read providers after switch");
let legacy = providers
.get("old-provider")
.expect("legacy provider should still exist");
assert_eq!(
legacy
.settings_config
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str()),
Some("db-key"),
"missing auth.json should fall back to the provider's stored auth during backfill"
);
assert_eq!(
legacy
.settings_config
.get("config")
.and_then(|v| v.as_str()),
Some(live_config),
"backfill should still capture the current live config.toml when auth.json is missing"
);
}
#[test]
fn provider_service_read_live_settings_uses_current_provider_auth_when_auth_json_missing() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
let live_config = r#"[mcp_servers.current]
type = "stdio"
command = "echo"
"#;
let config_path = get_codex_config_path();
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent).expect("create codex dir");
}
std::fs::write(&config_path, live_config).expect("seed codex config without auth.json");
let mut initial_config = MultiAppConfig::default();
{
let manager = initial_config
.get_manager_mut(&AppType::Codex)
.expect("codex manager");
manager.current = "current-provider".to_string();
manager.providers.insert(
"current-provider".to_string(),
Provider::with_id(
"current-provider".to_string(),
"Current".to_string(),
json!({
"auth": {"OPENAI_API_KEY": "db-key"},
"config": "provider-config"
}),
None,
),
);
}
let state = create_test_state_with_config(&initial_config).expect("create test state");
let settings = ProviderService::read_live_settings(&state, AppType::Codex)
.expect("should recover codex live settings from provider auth");
assert_eq!(
settings
.get("auth")
.and_then(|v| v.get("OPENAI_API_KEY"))
.and_then(|v| v.as_str()),
Some("db-key"),
"live settings should reuse stored provider auth when auth.json is missing"
);
assert_eq!(
settings.get("config").and_then(|v| v.as_str()),
Some(live_config),
"live settings should still read config.toml from disk"
);
}
#[test]
fn sync_current_provider_for_app_keeps_live_takeover_and_updates_restore_backup() {
let _guard = test_mutex().lock().expect("acquire test mutex");