Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 34 additions & 10 deletions apps/codex-plus-manager/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4375,7 +4375,12 @@ pub fn apply_relay_injection() -> CommandResult<RelayPayload> {
let relay = settings.active_relay_profile();
log_relay_apply_request("manager.apply_relay_injection", &settings, &relay);
if settings.active_aggregate_relay_profile().is_some() {
let response = apply_aggregate_relay_injection_to_home(&home);
let response = apply_aggregate_relay_injection_to_home(
&home,
&relay,
&relay_combined_common_config(&settings),
settings.computer_use_guard_enabled,
);
if response.status == "ok" {
finish_codex_app_state_after_provider_switch(
&home,
Expand Down Expand Up @@ -4483,15 +4488,17 @@ pub fn apply_relay_injection() -> CommandResult<RelayPayload> {
}
}

fn apply_aggregate_relay_injection_to_home(home: &Path) -> CommandResult<RelayPayload> {
match codex_plus_core::relay_config::apply_relay_config_to_home_with_protocol(
fn apply_aggregate_relay_injection_to_home(
home: &Path,
relay: &RelayProfile,
common_config_contents: &str,
preserve_computer_use_guard: bool,
) -> CommandResult<RelayPayload> {
match codex_plus_core::relay_config::apply_relay_profile_to_home_with_switch_rules_and_computer_use_guard(
home,
&codex_plus_core::protocol_proxy::local_responses_proxy_base_url(
codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT,
),
"codex-plus-aggregate",
codex_plus_core::settings::RelayProtocol::Responses,
codex_plus_core::protocol_proxy::DEFAULT_PROTOCOL_PROXY_PORT,
relay,
common_config_contents,
preserve_computer_use_guard,
) {
Ok(result) => {
let status = codex_plus_core::relay_config::relay_status_from_home(home);
Expand Down Expand Up @@ -5689,15 +5696,32 @@ mod tests {
#[test]
fn aggregate_relay_injection_writes_local_proxy_without_chatgpt_auth() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("auth.json"),
r#"{"tokens":{"access_token":"test-access-token"}}"#,
)
.unwrap();
let profile = RelayProfile {
relay_mode: codex_plus_core::settings::RelayMode::Aggregate,
..RelayProfile::default()
};

let result = apply_aggregate_relay_injection_to_home(temp.path());
let result = apply_aggregate_relay_injection_to_home(temp.path(), &profile, "", false);
let config = std::fs::read_to_string(temp.path().join("config.toml")).unwrap();
let auth: Value =
serde_json::from_str(&std::fs::read_to_string(temp.path().join("auth.json")).unwrap())
.unwrap();

assert_eq!(result.status, "ok");
assert!(result.payload.configured);
assert!(!result.payload.authenticated);
assert!(config.contains(r#"base_url = "http://127.0.0.1:57321/v1""#));
assert!(config.contains(r#"experimental_bearer_token = "codex-plus-aggregate""#));
assert_eq!(
auth["OPENAI_API_KEY"],
"sk-codex-plus-aggregate-placeholder"
);
assert_eq!(auth["tokens"]["access_token"], "test-access-token");
}

fn launch_request(sync_active_relay: bool) -> LaunchRequest {
Expand Down
140 changes: 136 additions & 4 deletions crates/codex-plus-core/src/relay_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -406,8 +406,14 @@ pub fn apply_relay_profile_to_home_with_switch_rules(
if profile.relay_mode == crate::settings::RelayMode::PureApi {
apply_relay_files_to_home(home, &compatible_config, &profile.auth_contents)
} else {
let auth_contents = official_profile_auth_for_switch(home, &profile.auth_contents)?;
apply_relay_files_to_home(home, &compatible_config, &auth_contents)
let auth_contents =
official_profile_auth_for_switch(home, &profile.auth_contents, profile.relay_mode)?;
apply_relay_files_to_home_with_computer_use_guard(
home,
&config_with_catalog,
&auth_contents,
preserve_computer_use_guard,
)
}
}

Expand Down Expand Up @@ -2190,6 +2196,12 @@ fn sync_profile_mode_from_backfilled_live(profile: &mut RelayProfile) {
if profile.relay_mode == crate::settings::RelayMode::Official && !profile.official_mix_api_key {
return;
}
// 聚合模式保持聚合模式:auth.json 中的占位 Key 仅为满足 Codex 桌面版
// 的登录检查(auth.json 存在 OPENAI_API_KEY 即视为已认证),实际请求认证
// 由本地协议代理完成。不能据此回填成 PureApi,否则会丢失聚合轮转语义。
if profile.relay_mode == crate::settings::RelayMode::Aggregate {
return;
}

if codex_auth_api_key(&profile.auth_contents)
.as_deref()
Expand All @@ -2209,15 +2221,68 @@ fn sync_profile_mode_from_backfilled_live(profile: &mut RelayProfile) {
}
}

fn official_profile_auth_for_switch(home: &Path, auth_contents: &str) -> anyhow::Result<String> {
let source = if auth_contents.trim().is_empty() {
/// 聚合模式写入 auth.json 的占位 Key。
///
/// 仅用于满足 Codex 桌面版的登录检查(auth.json 中存在 OPENAI_API_KEY 即视为
/// 已认证);实际请求认证由本地协议代理(127.0.0.1:57321)的内部 token
/// "codex-plus-aggregate" 完成,与 auth.json 内容无关。
const AGGREGATE_AUTH_PLACEHOLDER_KEY: &str = "sk-codex-plus-aggregate-placeholder";

/// 非 PureApi 模式切换时写入 auth.json 的认证内容。
///
/// - Official:Codex 桌面版依赖已登录的 ChatGPT 账号(MSIX 账户体系),
/// auth.json 中不保留 OPENAI_API_KEY。
/// - Aggregate:聚合模式的请求认证由本地协议代理完成,真实上游 Key 只保存在
/// Codex++ 的 settings.json 中。但 Codex 桌面版以 auth.json 的 OPENAI_API_KEY
/// 作为“已登录”判据,删除会导致纯 API Key 用户在切换聚合模式后弹出登录界面。
/// 因此聚合模式保留已有 Key,缺失时写入占位 Key。
fn official_profile_auth_for_switch(
home: &Path,
auth_contents: &str,
relay_mode: crate::settings::RelayMode,
) -> anyhow::Result<String> {
let source = if relay_mode == crate::settings::RelayMode::Aggregate {
// 聚合配置不拥有认证快照,始终基于实时 auth.json 补齐占位 Key,
// 避免旧 profile 覆盖用户刚更新的登录或 API Key 状态。
read_optional_text(&home.join("auth.json"))?
} else if auth_contents.trim().is_empty() {
read_optional_text(&home.join("auth.json"))?
} else {
auth_contents.to_string()
};
if relay_mode == crate::settings::RelayMode::Aggregate {
return ensure_openai_api_key_in_auth_contents(&source);
}
remove_openai_api_key_from_auth_contents(&source)
}

/// 聚合模式专用:确保 auth.json 包含 OPENAI_API_KEY。
/// 已有则原样保留;缺失或内容非法时写入占位 Key。
fn ensure_openai_api_key_in_auth_contents(auth_contents: &str) -> anyhow::Result<String> {
if !auth_contents.trim().is_empty() {
if let Ok(mut value) = serde_json::from_str::<Value>(auth_contents) {
if let Some(object) = value.as_object_mut() {
if object
.get("OPENAI_API_KEY")
.and_then(Value::as_str)
.is_some_and(|key| !key.trim().is_empty())
{
return Ok(format!("{}\n", serde_json::to_string_pretty(&value)?));
}
object.insert(
"OPENAI_API_KEY".to_string(),
Value::String(AGGREGATE_AUTH_PLACEHOLDER_KEY.to_string()),
);
return Ok(format!("{}\n", serde_json::to_string_pretty(&value)?));
}
}
}
Ok(format!(
"{}\n",
serde_json::to_string_pretty(&json!({ "OPENAI_API_KEY": AGGREGATE_AUTH_PLACEHOLDER_KEY }))?
))
}

fn codex_auth_api_key(auth_contents: &str) -> Option<String> {
let auth: Value = serde_json::from_str(auth_contents).ok()?;
auth.get("OPENAI_API_KEY")
Expand Down Expand Up @@ -2920,6 +2985,73 @@ mod tests {
};
assert!(relay_profile_model(&empty).trim().is_empty());
}

#[test]
fn aggregate_switch_prefers_live_auth_over_profile_snapshot() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("auth.json"),
"{\n \"OPENAI_API_KEY\": \"sk-live-key\",\n \"tokens\": {\"access_token\": \"live-token\"}\n}\n",
)
.unwrap();

let result = official_profile_auth_for_switch(
temp.path(),
r#"{"OPENAI_API_KEY":"sk-stale-profile-key"}"#,
crate::settings::RelayMode::Aggregate,
)
.unwrap();
let auth: Value = serde_json::from_str(&result).unwrap();

assert_eq!(auth["OPENAI_API_KEY"], "sk-live-key");
assert_eq!(auth["tokens"]["access_token"], "live-token");
assert!(!result.contains("sk-stale-profile-key"));
}

#[test]
fn aggregate_switch_writes_placeholder_when_auth_missing() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(temp.path().join("auth.json"), "").unwrap();

let result = official_profile_auth_for_switch(
temp.path(),
"",
crate::settings::RelayMode::Aggregate,
)
.unwrap();

assert!(result.contains("OPENAI_API_KEY"));
assert!(result.contains(AGGREGATE_AUTH_PLACEHOLDER_KEY));
}

#[test]
fn official_switch_still_removes_openai_api_key() {
let temp = tempfile::tempdir().unwrap();
std::fs::write(
temp.path().join("auth.json"),
"{\n \"OPENAI_API_KEY\": \"sk-should-be-removed\"\n}\n",
)
.unwrap();

let result =
official_profile_auth_for_switch(temp.path(), "", crate::settings::RelayMode::Official)
.unwrap();

assert!(!result.contains("OPENAI_API_KEY"));
}

#[test]
fn aggregate_backfill_keeps_mode_when_auth_has_placeholder_key() {
let mut profile = RelayProfile {
relay_mode: crate::settings::RelayMode::Aggregate,
auth_contents: format!("{{\"OPENAI_API_KEY\":\"{AGGREGATE_AUTH_PLACEHOLDER_KEY}\"}}"),
..RelayProfile::default()
};

sync_profile_mode_from_backfilled_live(&mut profile);

assert_eq!(profile.relay_mode, crate::settings::RelayMode::Aggregate);
}
}

pub fn root_key_string(contents: &str, key: &str) -> Option<String> {
Expand Down
21 changes: 19 additions & 2 deletions crates/codex-plus-core/tests/relay_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,22 +453,39 @@ base_url = "https://responses.example.test/v1"
#[test]
fn apply_aggregate_relay_points_codex_to_local_responses_proxy_without_snapshot() {
let temp = tempfile::tempdir().unwrap();
let profile = RelayProfile {
std::fs::write(
temp.path().join("auth.json"),
r#"{"tokens":{"access_token":"live-token"}}"#,
)
.unwrap();
let mut profile = RelayProfile {
id: "agg".to_string(),
name: "聚合供应商 1".to_string(),
relay_mode: RelayMode::Aggregate,
config_contents: String::new(),
auth_contents: String::new(),
auth_contents: r#"{"OPENAI_API_KEY":"sk-stale-profile-key"}"#.to_string(),
..RelayProfile::default()
};

let result = apply_relay_profile_to_home_with_switch_rules(temp.path(), &profile, "").unwrap();
let updated = std::fs::read_to_string(temp.path().join("config.toml")).unwrap();
let auth: serde_json::Value =
serde_json::from_str(&std::fs::read_to_string(temp.path().join("auth.json")).unwrap())
.unwrap();

assert!(result.configured);
assert!(updated.contains(r#"wire_api = "responses""#));
assert!(updated.contains(r#"base_url = "http://127.0.0.1:57321/v1""#));
assert!(updated.contains(r#"experimental_bearer_token = "codex-plus-aggregate""#));
assert_eq!(
auth["OPENAI_API_KEY"],
"sk-codex-plus-aggregate-placeholder"
);
assert_eq!(auth["tokens"]["access_token"], "live-token");

let mut common = String::new();
backfill_relay_profile_from_home_with_common(temp.path(), &mut profile, &mut common).unwrap();
assert_eq!(profile.relay_mode, RelayMode::Aggregate);
}

#[test]
Expand Down