mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-08-29 02:01:50 +08:00
fix(codex): preflight switches before committing current, share normalization with proxy paths
Four hardening fixes on top of the config-only unification: - Split write_codex_live_for_provider into a pure plan builder (normalization, safety gates, token injection, TOML parsing) and an executor, and preflight the plan in switch_normal BEFORE current is committed. Previously a write-layer refusal landed after local/DB current had moved, so the next switch would backfill the old live config into the refused provider's DB row. - Sink the legacy openai_base_url normalization into prepare_codex_provider_live_config, making it the single normalize→inject entry point. Takeover backup rebuilds and restore call prepare directly; without the migration a restored legacy backup left the key in a top-level field Codex ignores while auth.json credentials stayed live. - Stop update_codex_toml_field from creating [model_providers.openai]: Codex 0.149 rejects the whole config when a reserved built-in id is overridden (validate_reserved_model_provider_ids). The built-in openai provider is rerouted via the top-level openai_base_url knob instead, and wire_api writes become a no-op for it. - Never overwrite a user-authored [model_providers.cc-switch] table during normalization — nothing proves it is ours, and their headers or query params would be lost and backfilled into the DB. The shape falls through to the safety gates instead. The keyless-fallback rejection test now also locks that a refused switch leaves current on the previous provider.
This commit is contained in:
+186
-28
@@ -2705,6 +2705,16 @@ fn normalize_codex_legacy_openai_reroute(config_text: &str) -> Result<Option<Str
|
||||
if item.as_table().is_none() {
|
||||
return Ok(None);
|
||||
}
|
||||
// A user-authored table already claims our id: nothing proves it is
|
||||
// ours to overwrite (their headers/query params would be lost and
|
||||
// later backfilled into the DB for good). Leave the shape to the
|
||||
// safety gates, which refuse it with an actionable error.
|
||||
if item
|
||||
.as_table()
|
||||
.is_some_and(|table| table.contains_key(CODEX_MIGRATED_PROVIDER_ID))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
}
|
||||
|
||||
doc.as_table_mut().remove("openai_base_url");
|
||||
@@ -3188,11 +3198,23 @@ pub fn strip_codex_mcp_servers_from_settings(settings: &mut Value) -> Result<(),
|
||||
///
|
||||
/// 统一会话开关开启时,官方配置在落盘前注入共享的 `custom` 路由
|
||||
/// (见 `inject_codex_unified_session_bucket`)。
|
||||
pub fn write_codex_live_for_provider(
|
||||
/// A computed Codex live write. All validation (legacy-shape normalization,
|
||||
/// safety gates, token injection, TOML parsing) happens while building the
|
||||
/// plan, so callers can preflight a switch — build and discard — before
|
||||
/// committing any state, then execute the same computation for the real
|
||||
/// write. Keeping validation and execution in one builder makes it
|
||||
/// impossible for the two to drift apart.
|
||||
struct CodexLiveWritePlan {
|
||||
write_full_auth: bool,
|
||||
config_text: Option<String>,
|
||||
remove_auth_file: bool,
|
||||
}
|
||||
|
||||
fn plan_codex_live_write(
|
||||
category: Option<&str>,
|
||||
auth: &Value,
|
||||
config_text: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
) -> Result<CodexLiveWritePlan, AppError> {
|
||||
if category == Some("official") {
|
||||
let unified_official_config = if crate::settings::unify_codex_session_history() {
|
||||
Some(inject_codex_unified_session_bucket(
|
||||
@@ -3205,11 +3227,11 @@ pub fn write_codex_live_for_provider(
|
||||
// Official cards own auth.json: a material-carrying login is written
|
||||
// in full, a material-less card follows the live login and only
|
||||
// writes config. Official auth never travels through config.toml.
|
||||
return if codex_auth_has_login_material(auth) {
|
||||
write_codex_live_atomic(auth, config_text)
|
||||
} else {
|
||||
write_codex_live_config_atomic(config_text)
|
||||
};
|
||||
return Ok(CodexLiveWritePlan {
|
||||
write_full_auth: codex_auth_has_login_material(auth),
|
||||
config_text: config_text.map(str::to_string),
|
||||
remove_auth_file: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Third-party switches are config-only. Since Codex 0.149
|
||||
@@ -3229,13 +3251,22 @@ pub fn write_codex_live_for_provider(
|
||||
// The legacy reroute shape (built-in `openai` provider + top-level
|
||||
// `openai_base_url`) has no provider table to carry the key — rewrite it
|
||||
// into a cc-switch-owned custom table before the safety gates run.
|
||||
// prepare_codex_provider_live_config normalizes again internally
|
||||
// (idempotent); the gates need the normalized text here.
|
||||
let normalized = match config_text {
|
||||
Some(text) if carried_key.is_some() => normalize_codex_legacy_openai_reroute(text)?,
|
||||
_ => None,
|
||||
};
|
||||
let config_text = normalized.as_deref().or(config_text);
|
||||
|
||||
match config_text {
|
||||
// The preservation setting now means exactly one thing: does the
|
||||
// official login in auth.json survive a third-party switch? Off means
|
||||
// the file is deleted — a lingering login next to a third-party route is
|
||||
// the leak shape the gates exist to prevent, and `{}` is not logout, the
|
||||
// file must go (see clear_stale_codex_live_auth_after_official_switch).
|
||||
let remove_auth_file = !crate::settings::preserve_codex_official_auth_on_switch();
|
||||
|
||||
let live_config = match config_text {
|
||||
Some(text) if !text.trim().is_empty() => {
|
||||
// Both safety gates protect the same invariant: the auth Codex
|
||||
// resolves for a third-party route must never come from
|
||||
@@ -3257,26 +3288,46 @@ pub fn write_codex_live_for_provider(
|
||||
"This Codex config has no usable API key, and requires_openai_auth = true (or a top-level openai_base_url) would make Codex fall back to whatever login auth.json holds for a third-party route. Add an API key to the provider or remove the fallback directive",
|
||||
));
|
||||
}
|
||||
let live_config = prepare_codex_provider_live_config(auth, text)?;
|
||||
write_codex_live_config_atomic(Some(&live_config))?;
|
||||
prepare_codex_provider_live_config(auth, text)?
|
||||
}
|
||||
other => {
|
||||
// Empty config: with a key to carry this errs inside
|
||||
// set_codex_experimental_bearer_token (no table to attach it
|
||||
// to); without a key the empty config is written as-is.
|
||||
let live_config = prepare_codex_provider_live_config(auth, other.unwrap_or(""))?;
|
||||
write_codex_live_config_atomic(Some(&live_config))?;
|
||||
}
|
||||
}
|
||||
// Empty config: with a key to carry this errs inside
|
||||
// set_codex_experimental_bearer_token (no table to attach it to);
|
||||
// without a key the empty config is passed through as-is.
|
||||
other => prepare_codex_provider_live_config(auth, other.unwrap_or(""))?,
|
||||
};
|
||||
|
||||
// The preservation setting now means exactly one thing: does the
|
||||
// official login in auth.json survive a third-party switch? Off means
|
||||
// the file is deleted — a lingering login next to a third-party route is
|
||||
// the leak shape the gates exist to prevent, and `{}` is not logout, the
|
||||
// file must go (see clear_stale_codex_live_auth_after_official_switch).
|
||||
Ok(CodexLiveWritePlan {
|
||||
write_full_auth: false,
|
||||
config_text: Some(live_config),
|
||||
remove_auth_file,
|
||||
})
|
||||
}
|
||||
|
||||
/// Validate a Codex live write without touching the filesystem. Callers use
|
||||
/// this to fail a provider switch BEFORE committing `current`: a write-layer
|
||||
/// refusal after `current` moved would let the next switch backfill the old
|
||||
/// live config into the new provider's DB row.
|
||||
pub fn preflight_codex_live_write(
|
||||
category: Option<&str>,
|
||||
auth: &Value,
|
||||
config_text: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
plan_codex_live_write(category, auth, config_text).map(|_| ())
|
||||
}
|
||||
|
||||
pub fn write_codex_live_for_provider(
|
||||
category: Option<&str>,
|
||||
auth: &Value,
|
||||
config_text: Option<&str>,
|
||||
) -> Result<(), AppError> {
|
||||
let plan = plan_codex_live_write(category, auth, config_text)?;
|
||||
if plan.write_full_auth {
|
||||
return write_codex_live_atomic(auth, plan.config_text.as_deref());
|
||||
}
|
||||
write_codex_live_config_atomic(plan.config_text.as_deref())?;
|
||||
// Config is already committed at this point, so a cleanup failure
|
||||
// degrades to a warning instead of reporting an unswitched state.
|
||||
if !crate::settings::preserve_codex_official_auth_on_switch() {
|
||||
if plan.remove_auth_file {
|
||||
remove_codex_live_auth_after_third_party_switch();
|
||||
}
|
||||
Ok(())
|
||||
@@ -3298,6 +3349,13 @@ fn remove_codex_live_auth_after_third_party_switch() {
|
||||
/// requests can use a provider-scoped `experimental_bearer_token`, so switching
|
||||
/// providers only needs to update `config.toml`; `auth.json` stays as the user's
|
||||
/// long-lived ChatGPT login cache.
|
||||
///
|
||||
/// This is the single normalize→inject entry point: every caller — provider
|
||||
/// switches, takeover backup rebuilds (`preserve_codex_auth_in_backup`), and
|
||||
/// restore (`preserve_codex_oauth_login_on_restore`) — gets the legacy
|
||||
/// reroute migration, so a pre-0.149 `openai_base_url` shape can never leave
|
||||
/// its key in a top-level field Codex ignores while auth.json credentials
|
||||
/// stay live. Idempotent on already-normalized text.
|
||||
pub fn prepare_codex_provider_live_config(
|
||||
auth: &Value,
|
||||
config_text: &str,
|
||||
@@ -3305,10 +3363,12 @@ pub fn prepare_codex_provider_live_config(
|
||||
let token = extract_codex_auth_api_key(auth)
|
||||
.or_else(|| extract_codex_experimental_bearer_token(config_text));
|
||||
|
||||
Ok(match token {
|
||||
Some(token) => set_codex_experimental_bearer_token(config_text, &token)?,
|
||||
None => config_text.to_string(),
|
||||
})
|
||||
let Some(token) = token else {
|
||||
return Ok(config_text.to_string());
|
||||
};
|
||||
let normalized = normalize_codex_legacy_openai_reroute(config_text)?;
|
||||
let config_text = normalized.as_deref().unwrap_or(config_text);
|
||||
set_codex_experimental_bearer_token(config_text, &token)
|
||||
}
|
||||
|
||||
/// During DB backfill, lift a live `experimental_bearer_token` back into
|
||||
@@ -3389,6 +3449,23 @@ pub fn update_codex_toml_field(toml_str: &str, field: &str, value: &str) -> Resu
|
||||
.map(str::to_string);
|
||||
|
||||
if let Some(provider_key) = model_provider {
|
||||
// 0.149 的 validate_reserved_model_provider_ids 对配置里出现
|
||||
// `[model_providers.openai]` 等保留 id 表整份报错("Built-in
|
||||
// providers cannot be overridden"),Codex 直接起不来。内置
|
||||
// openai 的改址走它的正统机制——顶层 `openai_base_url`;
|
||||
// wire_api 由 CLI 内置固定,无需写。其他保留 id(ollama 等)
|
||||
// 没有等价旋钮,维持既有行为不在此扩大改动。
|
||||
if provider_key.eq_ignore_ascii_case("openai") {
|
||||
if field == "base_url" {
|
||||
if trimmed.is_empty() {
|
||||
doc.as_table_mut().remove("openai_base_url");
|
||||
} else {
|
||||
doc["openai_base_url"] = toml_edit::value(trimmed);
|
||||
}
|
||||
}
|
||||
return Ok(doc.to_string());
|
||||
}
|
||||
|
||||
// Ensure [model_providers] table exists
|
||||
//
|
||||
// 用 as_table_like_mut 而非 as_table_mut:用户把配置写成 inline table
|
||||
@@ -4491,6 +4568,87 @@ openai_base_url = "https://relay.example/v1"
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_reroute_normalization_never_overwrites_a_user_cc_switch_table() {
|
||||
// A user-authored [model_providers.cc-switch] proves nothing about
|
||||
// ownership — overwriting it would drop their headers/query params
|
||||
// and backfill the loss into the DB. The shape is left to the safety
|
||||
// gates instead.
|
||||
let conflicted = r#"model_provider = "openai"
|
||||
openai_base_url = "https://relay.example/v1"
|
||||
|
||||
[model_providers.cc-switch]
|
||||
name = "Mine"
|
||||
base_url = "https://mine.example/v1"
|
||||
http_headers = { x-team = "42" }
|
||||
"#;
|
||||
assert!(
|
||||
normalize_codex_legacy_openai_reroute(conflicted)
|
||||
.expect("normalize")
|
||||
.is_none(),
|
||||
"an existing cc-switch table must never be overwritten"
|
||||
);
|
||||
// The gate then refuses the shape with an actionable error.
|
||||
assert!(codex_config_routes_third_party_without_token_slot(
|
||||
conflicted
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prepare_normalizes_legacy_reroute_for_every_caller() {
|
||||
// prepare_codex_provider_live_config is the single normalize→inject
|
||||
// entry point — takeover backup rebuilds and restore call it directly,
|
||||
// so the legacy shape must be migrated here, not only in the switch
|
||||
// path's plan.
|
||||
let legacy = r#"model_provider = "openai"
|
||||
model = "gpt-5.4"
|
||||
openai_base_url = "https://relay.example/v1"
|
||||
"#;
|
||||
let prepared =
|
||||
prepare_codex_provider_live_config(&json!({"OPENAI_API_KEY": "sk-test"}), legacy)
|
||||
.expect("prepare live config");
|
||||
assert!(
|
||||
!prepared.contains("openai_base_url")
|
||||
&& prepared.contains("[model_providers.cc-switch]"),
|
||||
"prepare must rewrite the legacy reroute shape; got:\n{prepared}"
|
||||
);
|
||||
assert_eq!(
|
||||
extract_codex_experimental_bearer_token(&prepared).as_deref(),
|
||||
Some("sk-test"),
|
||||
"the key must land in the rewritten provider table"
|
||||
);
|
||||
// No token → nothing to protect, the shape passes through untouched.
|
||||
let untouched = prepare_codex_provider_live_config(&json!({}), legacy)
|
||||
.expect("prepare live config without token");
|
||||
assert_eq!(untouched, legacy);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_toml_field_reroutes_built_in_openai_via_top_level_knob() {
|
||||
// Codex 0.149 refuses any [model_providers.openai] table outright
|
||||
// (validate_reserved_model_provider_ids), so rewriting base_url for
|
||||
// the built-in provider must use the top-level openai_base_url knob.
|
||||
let input = "model_provider = \"openai\"\nmodel = \"gpt-5.4\"\n";
|
||||
let output = update_codex_toml_field(input, "base_url", "http://127.0.0.1:5000/v1")
|
||||
.expect("update base_url");
|
||||
assert!(
|
||||
!output.contains("[model_providers.openai]") && !output.contains("model_providers"),
|
||||
"no reserved provider table may be created; got:\n{output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("openai_base_url = \"http://127.0.0.1:5000/v1\""),
|
||||
"the reroute must use the top-level knob; got:\n{output}"
|
||||
);
|
||||
|
||||
// Clearing the value removes the knob again.
|
||||
let cleared = update_codex_toml_field(&output, "base_url", "").expect("clear base_url");
|
||||
assert!(!cleared.contains("openai_base_url"));
|
||||
|
||||
// wire_api is fixed by the CLI for built-ins — a no-op, not a table.
|
||||
let wire = update_codex_toml_field(input, "wire_api", "responses").expect("set wire_api");
|
||||
assert!(!wire.contains("model_providers"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bedrock_runtime_is_a_reserved_provider_id() {
|
||||
// `amazon-bedrock-runtime` is reserved by Codex 0.149; treating it as
|
||||
|
||||
@@ -714,6 +714,33 @@ pub(crate) fn write_live_with_common_config_for_state(
|
||||
)
|
||||
}
|
||||
|
||||
/// Validate the target provider's Codex live projection without writing:
|
||||
/// build the effective settings exactly like the live write would, then run
|
||||
/// the write-layer plan (legacy normalization, safety gates, token
|
||||
/// injection, TOML parsing). Called before `current` is committed — a
|
||||
/// write-layer refusal after `current` moved would let the next switch
|
||||
/// backfill the old live config into the new provider's DB row.
|
||||
pub(crate) fn preflight_codex_live_write_for_state(
|
||||
state: &AppState,
|
||||
provider: &Provider,
|
||||
) -> Result<(), AppError> {
|
||||
let effective = build_effective_provider_for_live_with_codex_oauth_manager(
|
||||
state.db.as_ref(),
|
||||
&AppType::Codex,
|
||||
provider,
|
||||
&state.codex_oauth_manager,
|
||||
)?;
|
||||
let obj = effective
|
||||
.settings_config
|
||||
.as_object()
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
|
||||
let auth = obj
|
||||
.get("auth")
|
||||
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
|
||||
let config_str = obj.get("config").and_then(|v| v.as_str());
|
||||
crate::codex_config::preflight_codex_live_write(effective.category.as_deref(), auth, config_str)
|
||||
}
|
||||
|
||||
pub(crate) fn write_live_with_common_config_for_codex_oauth_manager(
|
||||
db: &Database,
|
||||
app_type: &AppType,
|
||||
|
||||
@@ -5327,6 +5327,15 @@ impl ProviderService {
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// Codex: validate the live projection before committing current —
|
||||
// the write-layer safety gates can refuse the switch, and a
|
||||
// refusal after current moved would let the next switch backfill
|
||||
// the old live config into the new provider's DB row. (The
|
||||
// managed branch above has its own snapshot rollback instead.)
|
||||
if matches!(app_type, AppType::Codex) && preflighted_provider.is_none() {
|
||||
live::preflight_codex_live_write_for_state(state, provider)?;
|
||||
}
|
||||
|
||||
// Additive mode apps skip setting is_current (no such concept).
|
||||
if !app_type.is_additive_mode() {
|
||||
crate::settings::set_current_provider(&app_type, Some(id))?;
|
||||
|
||||
@@ -1107,6 +1107,15 @@ base_url = "https://relay.example/v1"
|
||||
wire_api = "responses"
|
||||
requires_openai_auth = true
|
||||
http_headers = { Authorization = "Bearer explicit-header-token" }
|
||||
"#;
|
||||
|
||||
let good_config = r#"model_provider = "good"
|
||||
model = "gpt-5.4"
|
||||
|
||||
[model_providers.good]
|
||||
name = "Good"
|
||||
base_url = "https://good.example/v1"
|
||||
wire_api = "responses"
|
||||
"#;
|
||||
|
||||
let mut initial_config = MultiAppConfig::default();
|
||||
@@ -1114,6 +1123,18 @@ http_headers = { Authorization = "Bearer explicit-header-token" }
|
||||
let manager = initial_config
|
||||
.get_manager_mut(&AppType::Codex)
|
||||
.expect("codex manager");
|
||||
manager.providers.insert(
|
||||
"good".to_string(),
|
||||
Provider::with_id(
|
||||
"good".to_string(),
|
||||
"Good".to_string(),
|
||||
json!({
|
||||
"auth": {"OPENAI_API_KEY": "sk-good"},
|
||||
"config": good_config
|
||||
}),
|
||||
None,
|
||||
),
|
||||
);
|
||||
manager.providers.insert(
|
||||
"header-auth".to_string(),
|
||||
Provider::with_id(
|
||||
@@ -1130,9 +1151,24 @@ http_headers = { Authorization = "Bearer explicit-header-token" }
|
||||
|
||||
let state = create_test_state_with_config(&initial_config).expect("create test state");
|
||||
|
||||
ProviderService::switch(&state, AppType::Codex, "good").expect("switch to the good provider");
|
||||
|
||||
ProviderService::switch(&state, AppType::Codex, "header-auth").expect_err(
|
||||
"preservation-on switch must fail when a keyless config falls back to the official auth",
|
||||
);
|
||||
|
||||
// The refusal happens in the pre-commit preflight: current must not move,
|
||||
// otherwise the next switch would backfill the good provider's live
|
||||
// config into the refused card's DB row.
|
||||
let current = state
|
||||
.db
|
||||
.get_current_provider(AppType::Codex.as_str())
|
||||
.expect("read current provider");
|
||||
assert_eq!(
|
||||
current.as_deref(),
|
||||
Some("good"),
|
||||
"a refused switch must leave current on the previous provider"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user