feat(codex): improve API/OAuth switching and configuration recovery
- Add Clear integration to configuration management - Guide users without OAuth credentials through clearing integration and signing in - Separate apply and close actions, showing Close only when configuration is applied - Preserve credentials and original settings across switches - Restore the CPA model catalog when reconnecting - Add configuration switching and UI regression tests
This commit is contained in:
1 parent
76da9b9944
commit
433182f9b0
21 files changed
+1530
-48
No files matched your search
@@ -3,6 +3,7 @@ use super::*;
|
||||
|
||||
mod commands;
|
||||
mod configuration;
|
||||
mod native_oauth;
|
||||
mod discovery;
|
||||
mod deepseek_harness;
|
||||
mod transactions;
|
||||
@@ -12,6 +13,7 @@ mod launch;
|
||||
mod state;
|
||||
pub(crate) use commands::*;
|
||||
pub(crate) use configuration::*;
|
||||
pub(crate) use native_oauth::*;
|
||||
pub(crate) use discovery::*;
|
||||
pub(crate) use deepseek_harness::*;
|
||||
pub(crate) use transactions::*;
|
||||
|
||||
@@ -539,6 +539,11 @@ pub(crate) fn write_agent_configuration_file(
|
||||
path: &Path,
|
||||
content: &[u8],
|
||||
) -> Result<(), String> {
|
||||
if client == AgentClient::Codex
|
||||
&& matches!(path.file_name().and_then(|name| name.to_str()), Some(CODEX_NATIVE_OAUTH_STATE_FILE | "auth.json"))
|
||||
{
|
||||
return write_codex_private_file(path, content);
|
||||
}
|
||||
if client == AgentClient::DeepSeekHarness {
|
||||
let owner_only = path.file_name().and_then(|name| name.to_str())
|
||||
== Some(DEEPSEEK_HARNESS_CREDENTIALS_FILE);
|
||||
@@ -2661,9 +2666,12 @@ pub(crate) fn build_codex_auth_update(
|
||||
) -> Result<AgentFileUpdate, String> {
|
||||
let path = codex_configuration_directory(home).join("auth.json");
|
||||
let after = if oauth_configuration {
|
||||
validate_codex_oauth_login_at(&path)?;
|
||||
read_optional_text(&path)?
|
||||
.ok_or_else(|| "Codex OAuth 登录凭据在应用配置前已被删除,请重新登录".to_string())?
|
||||
if validate_codex_oauth_login_at(&path).is_ok() {
|
||||
read_optional_text(&path)?
|
||||
.ok_or_else(|| "Codex OAuth 登录凭据在应用配置前已被删除,请重新登录".to_string())?
|
||||
} else {
|
||||
available_codex_oauth_auth(home)?
|
||||
}
|
||||
} else {
|
||||
let path_text = read_optional_text(&path)?;
|
||||
let mut root = parse_agent_json_object(path_text.as_deref(), "Codex auth.json")?;
|
||||
|
||||
@@ -432,6 +432,7 @@ pub(crate) fn inspect_pi_provider_status(
|
||||
connection_state: agent_connection_state(configured, config_valid, Ok(config_path.is_file() || default_provider_matches)).into(),
|
||||
current_model: current_model.clone(),
|
||||
oauth_configuration: false,
|
||||
codex_native_oauth: false,
|
||||
modification_enabled: configured,
|
||||
modification_state: modification_state.to_string(),
|
||||
backup_available: false,
|
||||
@@ -745,7 +746,10 @@ pub(crate) fn codex_auth_file_has_api_key(path: &Path, api_key: &str) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn validate_codex_oauth_login(home: &Path) -> Result<(), String> {
|
||||
validate_codex_oauth_login_at(&codex_configuration_directory(home).join("auth.json"))
|
||||
if validate_codex_oauth_login_at(&codex_configuration_directory(home).join("auth.json")).is_ok() {
|
||||
return Ok(());
|
||||
}
|
||||
available_codex_oauth_auth(home).map(|_| ())
|
||||
}
|
||||
|
||||
pub(crate) fn validate_codex_oauth_login_at(auth_path: &Path) -> Result<(), String> {
|
||||
@@ -757,11 +761,12 @@ pub(crate) fn validate_codex_oauth_login_at(auth_path: &Path) -> Result<(), Stri
|
||||
}
|
||||
|
||||
pub(crate) fn clear_codex_config_files(home: &Path) -> Result<Vec<String>, String> {
|
||||
let paths = config_paths("codex",home)?;
|
||||
let mut paths = config_paths("codex",home)?;
|
||||
paths.push(codex_configuration_directory(home).join(CODEX_NATIVE_OAUTH_STATE_FILE));
|
||||
let before = config_images(&paths)?;
|
||||
let mut after = before.clone();
|
||||
for (path, bytes) in &mut after {
|
||||
if path.file_name().and_then(|v| v.to_str()).is_some_and(|v| v == "auth.json" || v == "config.toml") { *bytes = None; }
|
||||
if path.file_name().and_then(|v| v.to_str()).is_some_and(|v| v == "auth.json" || v == "config.toml" || v == CODEX_NATIVE_OAUTH_STATE_FILE) { *bytes = None; }
|
||||
}
|
||||
Ok(commit_config("codex", &paths, &before, &after, "clear", None)?.changed_files)
|
||||
}
|
||||
@@ -884,7 +889,12 @@ pub(crate) fn inspect_agent_config(
|
||||
let paths = agent_config_paths(client, home);
|
||||
let config_exists = paths.iter().any(|path| path.is_file());
|
||||
let result = config_paths(client.id(), home)
|
||||
.and_then(|paths| config_images(&paths))
|
||||
.and_then(|mut paths| {
|
||||
if client == AgentClient::Codex && codex_native_oauth_enabled(home)? {
|
||||
paths.retain(|path| path.file_name().and_then(|name| name.to_str()) != Some(CODEX_MODEL_CATALOG_FILE));
|
||||
}
|
||||
config_images(&paths)
|
||||
})
|
||||
.and_then(|images| validate_config_images(&images))
|
||||
.and_then(|_| inspect_agent_managed_config(client, &paths, port, api_key)).and_then(
|
||||
|(configured, model, oauth_configuration)| {
|
||||
@@ -979,6 +989,7 @@ pub(crate) fn inspect_agent_config(
|
||||
connection_state: agent_connection_state(configured, config_valid, agent_has_connection_evidence(client, &paths)).into(),
|
||||
current_model: current_model.clone(),
|
||||
oauth_configuration,
|
||||
codex_native_oauth: client == AgentClient::Codex && codex_native_oauth_enabled(home).unwrap_or(false),
|
||||
modification_enabled: configured,
|
||||
modification_state: if !config_valid { "invalid" } else if configured { "applied" } else { "unconfigured" }.into(),
|
||||
backup_available: false,
|
||||
|
||||
@@ -0,0 +1,528 @@
|
||||
use super::*;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub(crate) const CODEX_NATIVE_OAUTH_STATE_FILE: &str = "cpa-native-oauth.json";
|
||||
pub(crate) fn write_codex_private_file(path: &Path, content: &[u8]) -> Result<(), String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
|
||||
fs::create_dir_all(path.parent().ok_or("Codex 凭据路径无效")?)
|
||||
.map_err(|_| "创建 Codex 凭据目录失败")?;
|
||||
let file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(false)
|
||||
.mode(0o600)
|
||||
.open(path)
|
||||
.map_err(|_| "无法打开 Codex 凭据文件")?;
|
||||
file.set_permissions(fs::Permissions::from_mode(0o600))
|
||||
.map_err(|_| "设置 Codex 凭据文件权限失败")?;
|
||||
}
|
||||
write_bytes_directly(path, content)
|
||||
}
|
||||
|
||||
const ROUTING_KEYS: &[&str] = &[
|
||||
"model_provider",
|
||||
"model",
|
||||
"model_catalog_json",
|
||||
"openai_base_url",
|
||||
"chatgpt_base_url",
|
||||
"forced_login_method",
|
||||
"profile",
|
||||
];
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct NativeOAuthState {
|
||||
version: u32,
|
||||
enabled: bool,
|
||||
cpa_settings: String,
|
||||
cpa_auth: Option<String>,
|
||||
native_auth: Option<String>,
|
||||
native_model: Option<String>,
|
||||
#[serde(default)]
|
||||
original_settings: Option<String>,
|
||||
#[serde(default)]
|
||||
original_auth: Option<String>,
|
||||
#[serde(default)]
|
||||
restore_full_routing: bool,
|
||||
}
|
||||
|
||||
fn parse_state(bytes: Option<&[u8]>) -> Result<NativeOAuthState, String> {
|
||||
let Some(bytes) = bytes else {
|
||||
return Ok(NativeOAuthState {
|
||||
version: 1,
|
||||
..Default::default()
|
||||
});
|
||||
};
|
||||
let state: NativeOAuthState = serde_json::from_slice(bytes)
|
||||
.map_err(|_| "无法读取 Codex 接入方式切换记录,请检查配置文件".to_string())?;
|
||||
if state.version != 1 {
|
||||
return Err("不支持的 Codex 接入方式切换记录版本".into());
|
||||
}
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn native_selected(document: &toml_edit::Document, state: &NativeOAuthState) -> bool {
|
||||
state.enabled
|
||||
&& document
|
||||
.get("model_provider")
|
||||
.and_then(toml_edit::Item::as_str)
|
||||
== Some("openai")
|
||||
&& document
|
||||
.get("forced_login_method")
|
||||
.and_then(toml_edit::Item::as_str)
|
||||
== Some("chatgpt")
|
||||
}
|
||||
|
||||
pub(crate) fn codex_native_oauth_enabled(home: &Path) -> Result<bool, String> {
|
||||
let directory = codex_configuration_directory(home);
|
||||
let state =
|
||||
parse_state(read_agent_bytes(&directory.join(CODEX_NATIVE_OAUTH_STATE_FILE))?.as_deref())?;
|
||||
if !state.enabled {
|
||||
return Ok(false);
|
||||
}
|
||||
let content = read_optional_text(&directory.join("config.toml"))?;
|
||||
let document = parse_codex_document(content.as_deref(), "Codex config.toml")?;
|
||||
Ok(native_selected(&document, &state))
|
||||
}
|
||||
|
||||
pub(crate) fn ensure_codex_cpa_mode(home: &Path) -> Result<(), String> {
|
||||
if codex_native_oauth_enabled(home)? {
|
||||
return Err("请点击“更新配置”或“一键接入”恢复 CPA 配置".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Save only the settings changed by this switch. Other user edits survive both directions.
|
||||
fn routing_settings(document: &toml_edit::Document) -> toml_edit::Document {
|
||||
let mut saved = toml_edit::Document::new();
|
||||
for key in ROUTING_KEYS {
|
||||
restore_codex_table_item(saved.as_table_mut(), Some(document.as_table()), key);
|
||||
}
|
||||
if let Some(openai) = document
|
||||
.get("model_providers")
|
||||
.and_then(toml_edit::Item::as_table_like)
|
||||
.and_then(|table| table.get("openai"))
|
||||
{
|
||||
ensure_toml_child_table(saved.as_table_mut(), "model_providers")
|
||||
.insert("openai", openai.clone());
|
||||
}
|
||||
saved
|
||||
}
|
||||
|
||||
fn remove_openai_override(document: &mut toml_edit::Document) {
|
||||
if let Some(providers) = document
|
||||
.get_mut("model_providers")
|
||||
.and_then(toml_edit::Item::as_table_like_mut)
|
||||
{
|
||||
providers.remove("openai");
|
||||
if providers.is_empty() {
|
||||
document.remove("model_providers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn oauth_auth(content: Option<&str>) -> Result<Option<String>, String> {
|
||||
let Some(content) = content else {
|
||||
return Ok(None);
|
||||
};
|
||||
let mut root = parse_agent_json_object(Some(content), "Codex auth.json")?;
|
||||
let has_tokens = root
|
||||
.get("tokens")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.is_some_and(|tokens| {
|
||||
["access_token", "refresh_token"].iter().any(|key| {
|
||||
tokens
|
||||
.get(*key)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|token| !token.trim().is_empty())
|
||||
})
|
||||
});
|
||||
if !has_tokens {
|
||||
return Ok(None);
|
||||
}
|
||||
if root.get("auth_mode").and_then(serde_json::Value::as_str) == Some("chatgpt")
|
||||
&& root
|
||||
.get("OPENAI_API_KEY")
|
||||
.is_none_or(serde_json::Value::is_null)
|
||||
{
|
||||
return Ok(Some(content.to_string()));
|
||||
}
|
||||
root.remove("OPENAI_API_KEY");
|
||||
root.insert("auth_mode".into(), serde_json::json!("chatgpt"));
|
||||
Ok(Some(render_agent_json(root, "Codex auth.json")?))
|
||||
}
|
||||
|
||||
fn auth_uses_api_key(content: Option<&str>) -> Result<bool, String> {
|
||||
let root = parse_agent_json_object(content, "Codex auth.json")?;
|
||||
Ok(
|
||||
root.get("auth_mode").and_then(serde_json::Value::as_str) == Some("apikey")
|
||||
|| root
|
||||
.get("OPENAI_API_KEY")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some_and(|key| !key.trim().is_empty()),
|
||||
)
|
||||
}
|
||||
|
||||
// Restore authentication fields only; user edits to other auth.json fields survive.
|
||||
fn merge_codex_auth(current: Option<&str>, source: Option<&str>) -> Result<Option<String>, String> {
|
||||
let mut root = parse_agent_json_object(current, "Codex auth.json")?;
|
||||
let original = root.clone();
|
||||
let source_content = source;
|
||||
let source = parse_agent_json_object(source, "Codex saved auth.json")?;
|
||||
for key in ["auth_mode", "OPENAI_API_KEY", "tokens", "last_refresh"] {
|
||||
root.remove(key);
|
||||
if let Some(value) = source.get(key) {
|
||||
root.insert(key.into(), value.clone());
|
||||
}
|
||||
}
|
||||
if root.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
if root == original {
|
||||
return Ok(current.map(str::to_string));
|
||||
}
|
||||
if root == source {
|
||||
return Ok(source_content.map(str::to_string));
|
||||
}
|
||||
Ok(Some(render_agent_json(root, "Codex auth.json")?))
|
||||
}
|
||||
|
||||
pub(crate) fn available_codex_oauth_auth(home: &Path) -> Result<String, String> {
|
||||
let directory = codex_configuration_directory(home);
|
||||
let current = read_optional_text(&directory.join("auth.json"))?;
|
||||
if let Some(auth) = oauth_auth(current.as_deref())? {
|
||||
return Ok(auth);
|
||||
}
|
||||
// A missing/cleared auth file represents logout, not a request to reuse old tokens.
|
||||
if auth_uses_api_key(current.as_deref())? {
|
||||
let state = parse_state(
|
||||
read_agent_bytes(&directory.join(CODEX_NATIVE_OAUTH_STATE_FILE))?.as_deref(),
|
||||
)?;
|
||||
if let Some(auth) = oauth_auth(state.native_auth.as_deref())? {
|
||||
return merge_codex_auth(current.as_deref(), Some(&auth))?
|
||||
.ok_or_else(|| CODEX_OAUTH_LOGIN_REQUIRED_ERROR.to_string());
|
||||
}
|
||||
}
|
||||
Err(CODEX_OAUTH_LOGIN_REQUIRED_ERROR.to_string())
|
||||
}
|
||||
|
||||
pub(crate) fn close_codex_configuration(home: &Path) -> Result<AgentConfigActionResult, String> {
|
||||
let directory = codex_configuration_directory(home);
|
||||
let paths = vec![
|
||||
directory.join(CODEX_NATIVE_OAUTH_STATE_FILE),
|
||||
directory.join("auth.json"),
|
||||
directory.join("config.toml"),
|
||||
];
|
||||
let before = config_images(&paths)?;
|
||||
let mut state = parse_state(before[0].1.as_deref())?;
|
||||
let current = parse_codex_document(text(before[2].1.as_deref())?, "Codex config.toml")?;
|
||||
if current
|
||||
.get("model_provider")
|
||||
.and_then(toml_edit::Item::as_str)
|
||||
!= Some(MANAGED_AGENT_PROVIDER_ID)
|
||||
{
|
||||
return Ok(action_result(
|
||||
"unchanged",
|
||||
false,
|
||||
None,
|
||||
Vec::new(),
|
||||
Vec::new(),
|
||||
));
|
||||
}
|
||||
let original = state.original_settings.as_deref();
|
||||
let restored = build_restored_codex_agent_config(text(before[2].1.as_deref())?, original)?;
|
||||
let mut document = parse_codex_document(restored.as_deref(), "Codex restored config.toml")?;
|
||||
if let Some(original) = original {
|
||||
let saved = parse_codex_document(Some(original), "Codex saved settings")?;
|
||||
for key in ROUTING_KEYS {
|
||||
if state.restore_full_routing || *key == "forced_login_method" {
|
||||
restore_codex_table_item(document.as_table_mut(), Some(saved.as_table()), key);
|
||||
}
|
||||
}
|
||||
if state.restore_full_routing {
|
||||
remove_openai_override(&mut document);
|
||||
if let Some(openai) = saved
|
||||
.get("model_providers")
|
||||
.and_then(toml_edit::Item::as_table_like)
|
||||
.and_then(|p| p.get("openai"))
|
||||
{
|
||||
ensure_toml_child_table(document.as_table_mut(), "model_providers")
|
||||
.insert("openai", openai.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
document.remove("forced_login_method");
|
||||
}
|
||||
let auth = text(before[1].1.as_deref())?;
|
||||
let next_auth = if auth_uses_api_key(state.original_auth.as_deref())? {
|
||||
merge_codex_auth(auth, state.original_auth.as_deref())?
|
||||
} else {
|
||||
let oauth = match oauth_auth(auth)? {
|
||||
Some(current) => Some(current),
|
||||
None if auth_uses_api_key(auth)? => oauth_auth(state.native_auth.as_deref())?,
|
||||
None => None,
|
||||
};
|
||||
merge_codex_auth(auth, oauth.as_deref())?
|
||||
};
|
||||
state.native_auth = oauth_auth(next_auth.as_deref())?;
|
||||
state.enabled = false;
|
||||
state.original_settings = None;
|
||||
state.original_auth = None;
|
||||
state.restore_full_routing = false;
|
||||
state.cpa_settings.clear();
|
||||
state.cpa_auth = None;
|
||||
let rendered = document.to_string();
|
||||
let after = vec![
|
||||
(
|
||||
paths[0].clone(),
|
||||
Some(serde_json::to_vec_pretty(&state).map_err(|_| "保存 Codex 切换记录失败")?),
|
||||
),
|
||||
(paths[1].clone(), next_auth.map(String::into_bytes)),
|
||||
(
|
||||
paths[2].clone(),
|
||||
if rendered.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(rendered.into_bytes())
|
||||
},
|
||||
),
|
||||
];
|
||||
let mut result = commit_config("codex", &paths, &before, &after, "close", None)?;
|
||||
result.enabled = false;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn close_codex_config_modification(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?;
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏")?;
|
||||
let result = close_codex_configuration(&home)?;
|
||||
app.state::<AgentConfigStatusCache>().clear()?;
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
fn prepare_native_oauth_switch(home: &Path, enabled: bool) -> Result<(Images, Images), String> {
|
||||
let directory = codex_configuration_directory(home);
|
||||
// Persist the recovery record first, then credentials, and activate routing last.
|
||||
// The shared transaction restores all three if any write or validation fails.
|
||||
let paths = vec![
|
||||
directory.join(CODEX_NATIVE_OAUTH_STATE_FILE),
|
||||
directory.join("auth.json"),
|
||||
directory.join("config.toml"),
|
||||
];
|
||||
let before = config_images(&paths)?;
|
||||
let mut state = parse_state(before[0].1.as_deref())?;
|
||||
let auth = text(before[1].1.as_deref())?;
|
||||
if let Some(auth) = auth {
|
||||
parse_agent_json_object(Some(auth), "Codex auth.json")?;
|
||||
}
|
||||
let mut document = parse_codex_document(text(before[2].1.as_deref())?, "Codex config.toml")?;
|
||||
let currently_native = native_selected(&document, &state);
|
||||
if !enabled && !currently_native {
|
||||
return Ok((before.clone(), before));
|
||||
}
|
||||
let next_auth = if enabled {
|
||||
if currently_native {
|
||||
state.native_model = document
|
||||
.get("model")
|
||||
.and_then(toml_edit::Item::as_str)
|
||||
.map(str::to_string);
|
||||
} else {
|
||||
state.cpa_settings = routing_settings(&document).to_string();
|
||||
state.cpa_auth = auth.map(str::to_string);
|
||||
}
|
||||
let next = match oauth_auth(auth)? {
|
||||
Some(current) => Some(current),
|
||||
None if !currently_native && auth_uses_api_key(auth)? => {
|
||||
oauth_auth(state.native_auth.as_deref())?
|
||||
}
|
||||
None => None,
|
||||
};
|
||||
for key in ROUTING_KEYS {
|
||||
document.remove(key);
|
||||
}
|
||||
remove_openai_override(&mut document);
|
||||
document["model_provider"] = toml_edit::value("openai");
|
||||
document["forced_login_method"] = toml_edit::value("chatgpt");
|
||||
if let Some(model) = state.native_model.as_deref() {
|
||||
document["model"] = toml_edit::value(model);
|
||||
}
|
||||
next
|
||||
} else {
|
||||
// Capture refreshed tokens (or a logout) every time, never reuse an older native cache.
|
||||
state.native_auth = oauth_auth(auth)?;
|
||||
state.native_model = document
|
||||
.get("model")
|
||||
.and_then(toml_edit::Item::as_str)
|
||||
.map(str::to_string);
|
||||
let saved = parse_codex_document(Some(&state.cpa_settings), "Codex CPA 接入配置")?;
|
||||
for key in ROUTING_KEYS {
|
||||
restore_codex_table_item(document.as_table_mut(), Some(saved.as_table()), key);
|
||||
}
|
||||
remove_openai_override(&mut document);
|
||||
if let Some(openai) = saved
|
||||
.get("model_providers")
|
||||
.and_then(toml_edit::Item::as_table_like)
|
||||
.and_then(|providers| providers.get("openai"))
|
||||
{
|
||||
if !document.contains_key("model_providers") {
|
||||
document["model_providers"] = toml_edit::Item::Table(toml_edit::Table::new());
|
||||
}
|
||||
document["model_providers"]
|
||||
.as_table_like_mut()
|
||||
.ok_or("Codex model_providers 格式无效")?
|
||||
.insert("openai", openai.clone());
|
||||
}
|
||||
if oauth_auth(state.cpa_auth.as_deref())?.is_some() {
|
||||
// CPA's existing OAuth mode shares the current account and its refreshed tokens.
|
||||
state.native_auth.clone()
|
||||
} else {
|
||||
state.cpa_auth.clone()
|
||||
}
|
||||
};
|
||||
state.enabled = enabled;
|
||||
let rendered = document.to_string();
|
||||
parse_codex_document(Some(&rendered), "Codex config.toml")?;
|
||||
let state_bytes =
|
||||
serde_json::to_vec_pretty(&state).map_err(|_| "保存 Codex 接入方式切换记录失败")?;
|
||||
let after = vec![
|
||||
(paths[0].clone(), Some(state_bytes)),
|
||||
(paths[1].clone(), next_auth.map(String::into_bytes)),
|
||||
(paths[2].clone(), Some(rendered.into_bytes())),
|
||||
];
|
||||
Ok((before, after))
|
||||
}
|
||||
|
||||
pub(crate) fn switch_codex_native_oauth(
|
||||
home: &Path,
|
||||
enabled: bool,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let (before, after) = prepare_native_oauth_switch(home, enabled)?;
|
||||
let paths = before
|
||||
.iter()
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
commit_config("codex", &paths, &before, &after, "native-oauth", None)
|
||||
}
|
||||
|
||||
// Save credentials and the pre-CPA routing in the same transaction as applying CPA.
|
||||
// Subsequent model/auth updates retain the original routing until Close is used.
|
||||
pub(crate) fn apply_codex_cpa_configuration(
|
||||
home: &Path,
|
||||
port: u16,
|
||||
api_key: &str,
|
||||
model: &str,
|
||||
options: AgentConfigurationOptions<'_>,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let (native_before, mut native_after) = prepare_native_oauth_switch(home, false)?;
|
||||
let mut state = parse_state(native_after[0].1.as_deref())?;
|
||||
let current_document =
|
||||
parse_codex_document(text(native_before[2].1.as_deref())?, "Codex config.toml")?;
|
||||
let auth = text(native_before[1].1.as_deref())?;
|
||||
if current_document
|
||||
.get("model_provider")
|
||||
.and_then(toml_edit::Item::as_str)
|
||||
!= Some(MANAGED_AGENT_PROVIDER_ID)
|
||||
{
|
||||
state.restore_full_routing = native_selected(
|
||||
¤t_document,
|
||||
&parse_state(native_before[0].1.as_deref())?,
|
||||
);
|
||||
state.original_settings = Some(routing_settings(¤t_document).to_string());
|
||||
state.original_auth = auth.map(str::to_string);
|
||||
}
|
||||
if let Some(auth) = oauth_auth(auth)? {
|
||||
state.native_auth = Some(auth);
|
||||
} else if !auth_uses_api_key(auth)? {
|
||||
state.native_auth = None;
|
||||
}
|
||||
native_after[0].1 =
|
||||
Some(serde_json::to_vec_pretty(&state).map_err(|_| "保存 Codex 切换记录失败")?);
|
||||
let config_paths = config_paths("codex", home)?;
|
||||
let current = config_images(&config_paths)?;
|
||||
for image in ¤t {
|
||||
if native_before
|
||||
.iter()
|
||||
.any(|(path, bytes)| path == &image.0 && bytes != &image.1)
|
||||
{
|
||||
return Err("配置已被其他程序修改,请刷新后重试".into());
|
||||
}
|
||||
}
|
||||
let mut restored_config = native_after[2].clone();
|
||||
let mut restored_document =
|
||||
parse_codex_document(text(restored_config.1.as_deref())?, "Codex CPA 配置")?;
|
||||
if restored_document.contains_key("forced_login_method") {
|
||||
restored_document["forced_login_method"] =
|
||||
toml_edit::value(if options.oauth_configuration {
|
||||
"chatgpt"
|
||||
} else {
|
||||
"api"
|
||||
});
|
||||
}
|
||||
restored_config.1 = Some(restored_document.to_string().into_bytes());
|
||||
let mut baseline = current.clone();
|
||||
baseline
|
||||
.iter_mut()
|
||||
.find(|(path, _)| path == &restored_config.0)
|
||||
.ok_or("Codex 配置路径不匹配")?
|
||||
.1 = restored_config.1.clone();
|
||||
let catalog = options.codex_catalog.ok_or("无法生成 Codex 模型目录")?;
|
||||
validate_codex_catalog(catalog, model)?;
|
||||
let updates = vec![
|
||||
AgentFileUpdate {
|
||||
path: restored_config.0.clone(),
|
||||
after: build_codex_agent_config_with_oauth(
|
||||
text(restored_config.1.as_deref())?,
|
||||
&format!("{}/v1", managed_core_loopback_origin(port)),
|
||||
api_key,
|
||||
model,
|
||||
options.oauth_configuration,
|
||||
)?,
|
||||
},
|
||||
AgentFileUpdate {
|
||||
path: codex_model_catalog_path(home),
|
||||
after: catalog.to_string(),
|
||||
},
|
||||
build_codex_auth_update(home, api_key, options.oauth_configuration)?,
|
||||
];
|
||||
let updated = prepare_config_updates("codex", &config_paths, &baseline, &updates, false)?;
|
||||
let mut before = vec![native_before[0].clone()];
|
||||
let mut after = vec![native_after[0].clone()];
|
||||
// Save refreshed OAuth credentials first and activate the CPA provider last.
|
||||
for index in [1, 2, 0] {
|
||||
before.push(current[index].clone());
|
||||
after.push(updated[index].clone());
|
||||
}
|
||||
let paths = before
|
||||
.iter()
|
||||
.map(|(path, _)| path.clone())
|
||||
.collect::<Vec<_>>();
|
||||
commit_config(
|
||||
"codex",
|
||||
&paths,
|
||||
&before,
|
||||
&after,
|
||||
"update",
|
||||
Some(model.to_string()),
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn restore_codex_official_config(
|
||||
app: tauri::AppHandle,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
let home = app.path().home_dir().map_err(|_| "无法获取用户目录")?;
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏")?;
|
||||
let result = switch_codex_native_oauth(&home, true)?;
|
||||
app.state::<AgentConfigStatusCache>().clear()?;
|
||||
Ok(result)
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
use super::*;
|
||||
|
||||
struct TestHome(PathBuf);
|
||||
impl TestHome {
|
||||
fn new() -> Self {
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"cpa-native-oauth-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
fs::create_dir_all(path.join(".codex")).unwrap();
|
||||
Self(path)
|
||||
}
|
||||
fn path(&self, name: &str) -> PathBuf {
|
||||
self.0.join(".codex").join(name)
|
||||
}
|
||||
fn write(&self, name: &str, content: &str) {
|
||||
fs::write(self.path(name), content).unwrap();
|
||||
}
|
||||
fn read(&self, name: &str) -> String {
|
||||
fs::read_to_string(self.path(name)).unwrap()
|
||||
}
|
||||
fn config(&self) -> toml::Value {
|
||||
toml::from_str(&self.read("config.toml")).unwrap()
|
||||
}
|
||||
}
|
||||
impl Drop for TestHome {
|
||||
fn drop(&mut self) {
|
||||
let _ = fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
const CPA_CONFIG: &str = r#"model_provider = "cpa-gui"
|
||||
model = "cpa-alias"
|
||||
model_catalog_json = "cpa-gui-model-catalog.json"
|
||||
openai_base_url = "http://localhost:8317/v1"
|
||||
chatgpt_base_url = "http://localhost:8317/chatgpt"
|
||||
forced_login_method = "api"
|
||||
profile = "legacy-cpa"
|
||||
# keep this comment
|
||||
approval_policy = "on-request"
|
||||
[mcp_servers.example]
|
||||
command = "example"
|
||||
[model_providers.cpa-gui]
|
||||
name = "EasyCLIProxyAPI"
|
||||
base_url = "http://127.0.0.1:8317/v1"
|
||||
wire_api = "responses"
|
||||
experimental_bearer_token = "test-api-key"
|
||||
[model_providers.openai]
|
||||
base_url = "http://localhost:8317/v1"
|
||||
"#;
|
||||
const OAUTH: &str = r#"{"auth_mode":"chatgpt","tokens":{"access_token":"test-token","refresh_token":"test-refresh"},"last_refresh":"today","custom":"keep"}"#;
|
||||
|
||||
#[test]
|
||||
fn native_oauth_round_trip_preserves_cpa_and_unrelated_user_edits() {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", CPA_CONFIG);
|
||||
let api = build_codex_api_auth("test-api-key").unwrap();
|
||||
home.write("auth.json", &api);
|
||||
home.write(CODEX_MODEL_CATALOG_FILE, "existing catalog");
|
||||
let original = home.config();
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert!(codex_native_oauth_enabled(&home.0).unwrap());
|
||||
let native = home.config();
|
||||
assert_eq!(native["model_provider"].as_str(), Some("openai"));
|
||||
assert_eq!(native["forced_login_method"].as_str(), Some("chatgpt"));
|
||||
for key in [
|
||||
"model",
|
||||
"model_catalog_json",
|
||||
"openai_base_url",
|
||||
"chatgpt_base_url",
|
||||
"profile",
|
||||
] {
|
||||
assert!(native.get(key).is_none(), "{key}");
|
||||
}
|
||||
assert!(native["model_providers"].get("openai").is_none());
|
||||
assert_eq!(
|
||||
native["model_providers"]["cpa-gui"],
|
||||
original["model_providers"]["cpa-gui"]
|
||||
);
|
||||
assert!(!home.path("auth.json").exists());
|
||||
assert!(ensure_codex_cpa_mode(&home.0).is_err());
|
||||
assert!(
|
||||
!sync_codex_model_catalog_if_configured(&home.0, 8317, "test-api-key", &[], "unused")
|
||||
.unwrap()
|
||||
);
|
||||
home.write("auth.json", OAUTH);
|
||||
home.write(
|
||||
"config.toml",
|
||||
&home.read("config.toml").replace("on-request", "never"),
|
||||
);
|
||||
switch_codex_native_oauth(&home.0, false).unwrap();
|
||||
assert!(!codex_native_oauth_enabled(&home.0).unwrap());
|
||||
let restored = home.config();
|
||||
for key in ROUTING_KEYS {
|
||||
assert_eq!(restored.get(*key), original.get(*key), "{key}");
|
||||
}
|
||||
assert_eq!(restored["model_providers"], original["model_providers"]);
|
||||
assert_eq!(restored["approval_policy"].as_str(), Some("never"));
|
||||
assert!(home.read("config.toml").contains("# keep this comment"));
|
||||
assert_eq!(home.read("auth.json"), api);
|
||||
assert_eq!(home.read(CODEX_MODEL_CATALOG_FILE), "existing catalog");
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert_eq!(home.read("auth.json"), OAUTH);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_oauth_retains_refreshed_tokens_and_native_model_across_switches() {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", CPA_CONFIG);
|
||||
home.write("auth.json", OAUTH);
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert_eq!(home.read("auth.json"), OAUTH);
|
||||
let refreshed = OAUTH.replace("test-refresh", "rotated-refresh");
|
||||
home.write("auth.json", &refreshed);
|
||||
home.write(
|
||||
"config.toml",
|
||||
&("model = \"native-model\"\n".to_string() + &home.read("config.toml")),
|
||||
);
|
||||
switch_codex_native_oauth(&home.0, false).unwrap();
|
||||
assert_eq!(home.read("auth.json"), refreshed);
|
||||
assert_eq!(home.config()["model"].as_str(), Some("cpa-alias"));
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert_eq!(home.config()["model"].as_str(), Some("native-model"));
|
||||
assert_eq!(home.read("auth.json"), refreshed);
|
||||
fs::remove_file(home.path("auth.json")).unwrap();
|
||||
switch_codex_native_oauth(&home.0, false).unwrap();
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert!(
|
||||
!home.path("auth.json").exists(),
|
||||
"Never resurrect credentials after the user signs out"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_oauth_can_be_enabled_before_first_login_and_is_idempotent() {
|
||||
let home = TestHome::new();
|
||||
assert_eq!(
|
||||
switch_codex_native_oauth(&home.0, false).unwrap().outcome,
|
||||
"unchanged"
|
||||
);
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert!(!home.path("auth.json").exists());
|
||||
let paths = [
|
||||
home.path("config.toml"),
|
||||
home.path(CODEX_NATIVE_OAUTH_STATE_FILE),
|
||||
];
|
||||
let before = config_images(&paths).unwrap();
|
||||
assert_eq!(
|
||||
switch_codex_native_oauth(&home.0, true).unwrap().outcome,
|
||||
"unchanged"
|
||||
);
|
||||
assert_eq!(config_images(&paths).unwrap(), before);
|
||||
switch_codex_native_oauth(&home.0, false).unwrap();
|
||||
assert!(home.config().as_table().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_oauth_rejects_malformed_files_without_partial_updates() {
|
||||
for name in ["config.toml", "auth.json", CODEX_NATIVE_OAUTH_STATE_FILE] {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", CPA_CONFIG);
|
||||
home.write("auth.json", OAUTH);
|
||||
home.write(name, "malformed [");
|
||||
let paths = [
|
||||
home.path("config.toml"),
|
||||
home.path("auth.json"),
|
||||
home.path(CODEX_NATIVE_OAUTH_STATE_FILE),
|
||||
];
|
||||
let before = config_images(&paths).unwrap();
|
||||
assert!(switch_codex_native_oauth(&home.0, true).is_err());
|
||||
assert_eq!(config_images(&paths).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_oauth_does_not_select_api_key_when_tokens_also_exist() {
|
||||
let auth = OAUTH.replace(
|
||||
"\"auth_mode\":\"chatgpt\"",
|
||||
"\"auth_mode\":\"apikey\",\"OPENAI_API_KEY\":\"old-key\"",
|
||||
);
|
||||
let normalized: serde_json::Value =
|
||||
serde_json::from_str(&oauth_auth(Some(&auth)).unwrap().unwrap()).unwrap();
|
||||
assert_eq!(normalized["auth_mode"], "chatgpt");
|
||||
assert!(normalized.get("OPENAI_API_KEY").is_none());
|
||||
assert_eq!(normalized["tokens"]["refresh_token"], "test-refresh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_codex_removes_native_oauth_recovery_credentials() {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", CPA_CONFIG);
|
||||
home.write("auth.json", OAUTH);
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
clear_codex_config_files(&home.0).unwrap();
|
||||
assert!(!home.path(CODEX_NATIVE_OAUTH_STATE_FILE).exists());
|
||||
assert!(!home.path("auth.json").exists());
|
||||
assert!(!codex_native_oauth_enabled(&home.0).unwrap());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn native_oauth_credential_cache_is_owner_only() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", CPA_CONFIG);
|
||||
home.write("auth.json", OAUTH);
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert_eq!(
|
||||
fs::metadata(home.path(CODEX_NATIVE_OAUTH_STATE_FILE))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode()
|
||||
& 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn native_oauth_handles_inline_provider_tables_without_losing_other_providers() {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", "model_provider = \"cpa-gui\"\nmodel_providers = { openai = { base_url = \"http://localhost:8317/v1\" }, other = { base_url = \"http://localhost:9999\" } }\n");
|
||||
let original = home.config();
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert!(home.config()["model_providers"].get("openai").is_none());
|
||||
assert_eq!(
|
||||
home.config()["model_providers"]["other"],
|
||||
original["model_providers"]["other"]
|
||||
);
|
||||
switch_codex_native_oauth(&home.0, false).unwrap();
|
||||
assert_eq!(home.config(), original);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restore_official_configuration_always_removes_the_cpa_catalog_setting() {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", CPA_CONFIG);
|
||||
home.write("auth.json", OAUTH);
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert!(home.config().get("model_catalog_json").is_none());
|
||||
home.write(
|
||||
"config.toml",
|
||||
&format!(
|
||||
"model_catalog_json = \"{CODEX_MODEL_CATALOG_FILE}\"\nmodel = \"native-choice\"\n{}",
|
||||
home.read("config.toml")
|
||||
),
|
||||
);
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert!(home.config().get("model_catalog_json").is_none());
|
||||
assert_eq!(home.config()["model"].as_str(), Some("native-choice"));
|
||||
switch_codex_native_oauth(&home.0, false).unwrap();
|
||||
assert_eq!(home.config()["model"].as_str(), Some("cpa-alias"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_configuration_reconnects_cpa_and_restores_catalog_after_official_restore() {
|
||||
for oauth_configuration in [false, true] {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", CPA_CONFIG);
|
||||
home.write("auth.json", OAUTH);
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
let refreshed = OAUTH.replace("test-refresh", "latest-refresh");
|
||||
home.write("auth.json", &refreshed);
|
||||
let catalog = r#"{"models":[{"slug":"new-cpa-model"}]}"#;
|
||||
let result = apply_agent_configuration_with_oauth(
|
||||
AgentClient::Codex,
|
||||
&home.0,
|
||||
9321,
|
||||
"new-cpa-key",
|
||||
"new-cpa-model",
|
||||
AgentConfigurationOptions {
|
||||
models: &[],
|
||||
codex_catalog: Some(catalog),
|
||||
oauth_configuration,
|
||||
claude_code_model_mappings: None,
|
||||
claude_desktop_model_mappings: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(result.outcome, "updated");
|
||||
assert!(!codex_native_oauth_enabled(&home.0).unwrap());
|
||||
let config = home.config();
|
||||
assert_eq!(config["model_provider"].as_str(), Some("cpa-gui"));
|
||||
assert_eq!(
|
||||
config["model_catalog_json"].as_str(),
|
||||
Some(CODEX_MODEL_CATALOG_FILE)
|
||||
);
|
||||
assert_eq!(config["model"].as_str(), Some("new-cpa-model"));
|
||||
assert_eq!(
|
||||
config["model_providers"]["cpa-gui"]["base_url"].as_str(),
|
||||
Some("http://127.0.0.1:9321/v1")
|
||||
);
|
||||
assert_eq!(
|
||||
config["model_providers"]["cpa-gui"]["experimental_bearer_token"].as_str(),
|
||||
Some("new-cpa-key")
|
||||
);
|
||||
assert_eq!(
|
||||
config["forced_login_method"].as_str(),
|
||||
Some(if oauth_configuration {
|
||||
"chatgpt"
|
||||
} else {
|
||||
"api"
|
||||
})
|
||||
);
|
||||
validate_codex_catalog_file(&home.path("config.toml"), "new-cpa-model").unwrap();
|
||||
if oauth_configuration {
|
||||
assert_eq!(home.read("auth.json"), refreshed);
|
||||
} else {
|
||||
assert!(codex_auth_file_has_api_key(
|
||||
&home.path("auth.json"),
|
||||
"new-cpa-key"
|
||||
));
|
||||
}
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert_eq!(home.read("auth.json"), refreshed);
|
||||
assert!(home.config().get("model_catalog_json").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_cpa(
|
||||
home: &TestHome,
|
||||
oauth_configuration: bool,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
apply_agent_configuration_with_oauth(
|
||||
AgentClient::Codex,
|
||||
&home.0,
|
||||
8317,
|
||||
"test-api-key",
|
||||
"cpa-alias",
|
||||
AgentConfigurationOptions {
|
||||
models: &[],
|
||||
codex_catalog: Some(r#"{"models":[{"slug":"cpa-alias"}]}"#),
|
||||
oauth_configuration,
|
||||
claude_code_model_mappings: None,
|
||||
claude_desktop_model_mappings: None,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_apply_close_restores_original_login_and_preserves_user_edits() {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", "model = \"official-model\"\nforced_login_method = \"chatgpt\"\napproval_policy = \"on-request\"\n[mcp_servers.example]\ncommand = \"example\"\n");
|
||||
home.write("auth.json", OAUTH);
|
||||
let original = home.config();
|
||||
apply_cpa(&home, false).unwrap();
|
||||
assert_eq!(home.config()["model_provider"].as_str(), Some("cpa-gui"));
|
||||
assert_eq!(home.config()["forced_login_method"].as_str(), Some("api"));
|
||||
assert!(codex_auth_file_has_api_key(
|
||||
&home.path("auth.json"),
|
||||
"test-api-key"
|
||||
));
|
||||
assert!(
|
||||
validate_codex_oauth_login(&home.0).is_ok(),
|
||||
"Saved login remains selectable after API mode"
|
||||
);
|
||||
home.write(
|
||||
"config.toml",
|
||||
&format!(
|
||||
"openai_base_url = \"https://example.test/edited\"\n{}",
|
||||
home.read("config.toml").replace("on-request", "never")
|
||||
),
|
||||
);
|
||||
let mut api: serde_json::Value = serde_json::from_str(&home.read("auth.json")).unwrap();
|
||||
api["custom"] = serde_json::json!("edited");
|
||||
home.write("auth.json", &api.to_string());
|
||||
apply_cpa(&home, true).unwrap();
|
||||
let refreshed = home
|
||||
.read("auth.json")
|
||||
.replace("test-refresh", "new-refresh");
|
||||
home.write("auth.json", &refreshed);
|
||||
apply_cpa(&home, false).unwrap();
|
||||
close_codex_configuration(&home.0).unwrap();
|
||||
let restored = home.config();
|
||||
assert_eq!(restored["model"], original["model"]);
|
||||
assert_eq!(
|
||||
restored["forced_login_method"],
|
||||
original["forced_login_method"]
|
||||
);
|
||||
assert_eq!(restored["approval_policy"].as_str(), Some("never"));
|
||||
assert_eq!(restored["mcp_servers"], original["mcp_servers"]);
|
||||
assert_eq!(
|
||||
restored["openai_base_url"].as_str(),
|
||||
Some("https://example.test/edited")
|
||||
);
|
||||
assert!(restored.get("model_catalog_json").is_none());
|
||||
assert!(restored.get("model_providers").is_none());
|
||||
let auth: serde_json::Value = serde_json::from_str(&home.read("auth.json")).unwrap();
|
||||
assert_eq!(auth["tokens"]["refresh_token"], "new-refresh");
|
||||
assert_eq!(auth["custom"], "edited");
|
||||
assert!(auth.get("OPENAI_API_KEY").is_none());
|
||||
assert_eq!(
|
||||
close_codex_configuration(&home.0).unwrap().outcome,
|
||||
"unchanged"
|
||||
);
|
||||
apply_cpa(&home, false).unwrap();
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert!(
|
||||
home.read("auth.json").contains("new-refresh"),
|
||||
"Official restore also uses the saved login"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_close_existing_cpa_without_recovery_record_removes_managed_fields() {
|
||||
let home = TestHome::new();
|
||||
home.write(
|
||||
"config.toml",
|
||||
&build_codex_agent_config_with_oauth(
|
||||
Some("approval_policy = \"never\"\n"),
|
||||
"http://127.0.0.1:8317/v1",
|
||||
"test-api-key",
|
||||
"cpa-alias",
|
||||
false,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
home.write("auth.json", &build_codex_api_auth("test-api-key").unwrap());
|
||||
close_codex_configuration(&home.0).unwrap();
|
||||
assert_eq!(home.config()["approval_policy"].as_str(), Some("never"));
|
||||
assert!(home.config().get("model_provider").is_none());
|
||||
assert!(home.config().get("model_catalog_json").is_none());
|
||||
assert!(home.config().get("model_providers").is_none());
|
||||
assert!(!home.path("auth.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_close_restores_preexisting_api_provider_and_key() {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", "model_provider = \"other\"\nmodel = \"other-model\"\n[model_providers.other]\nbase_url = \"https://example.test/v1\"\n");
|
||||
home.write("auth.json", &build_codex_api_auth("original-key").unwrap());
|
||||
let original = home.config();
|
||||
apply_cpa(&home, false).unwrap();
|
||||
close_codex_configuration(&home.0).unwrap();
|
||||
assert_eq!(home.config(), original);
|
||||
assert!(codex_auth_file_has_api_key(
|
||||
&home.path("auth.json"),
|
||||
"original-key"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_close_does_not_resurrect_an_explicit_logout() {
|
||||
let home = TestHome::new();
|
||||
home.write("auth.json", OAUTH);
|
||||
apply_cpa(&home, true).unwrap();
|
||||
fs::remove_file(home.path("auth.json")).unwrap();
|
||||
assert!(validate_codex_oauth_login(&home.0).is_err());
|
||||
apply_cpa(&home, false).unwrap();
|
||||
assert!(validate_codex_oauth_login(&home.0).is_err());
|
||||
close_codex_configuration(&home.0).unwrap();
|
||||
assert!(!home.path("auth.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_official_restore_does_not_reuse_credentials_after_logout() {
|
||||
let home = TestHome::new();
|
||||
home.write("auth.json", OAUTH);
|
||||
apply_cpa(&home, true).unwrap();
|
||||
fs::remove_file(home.path("auth.json")).unwrap();
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
assert!(!home.path("auth.json").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_close_after_official_reconnect_restores_official_routing() {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", CPA_CONFIG);
|
||||
home.write("auth.json", OAUTH);
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
apply_cpa(&home, false).unwrap();
|
||||
close_codex_configuration(&home.0).unwrap();
|
||||
assert_eq!(home.config()["model_provider"].as_str(), Some("openai"));
|
||||
assert_eq!(
|
||||
home.config()["forced_login_method"].as_str(),
|
||||
Some("chatgpt")
|
||||
);
|
||||
for key in [
|
||||
"model_catalog_json",
|
||||
"openai_base_url",
|
||||
"chatgpt_base_url",
|
||||
"profile",
|
||||
] {
|
||||
assert!(home.config().get(key).is_none(), "{key}");
|
||||
}
|
||||
assert_eq!(home.read("auth.json"), OAUTH);
|
||||
assert!(!codex_native_oauth_enabled(&home.0).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_close_rejects_invalid_state_without_changing_any_files() {
|
||||
for name in ["config.toml", "auth.json", CODEX_NATIVE_OAUTH_STATE_FILE] {
|
||||
let home = TestHome::new();
|
||||
home.write("auth.json", OAUTH);
|
||||
apply_cpa(&home, false).unwrap();
|
||||
home.write(name, "invalid [");
|
||||
let paths = vec![
|
||||
home.path("config.toml"),
|
||||
home.path("auth.json"),
|
||||
home.path(CODEX_NATIVE_OAUTH_STATE_FILE),
|
||||
];
|
||||
let before = config_images(&paths).unwrap();
|
||||
assert!(close_codex_configuration(&home.0).is_err());
|
||||
assert_eq!(config_images(&paths).unwrap(), before);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_failed_api_apply_preserves_login_and_recovery_record() {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", "model = \"official-model\"\n");
|
||||
home.write("auth.json", OAUTH);
|
||||
home.write(CODEX_MODEL_CATALOG_FILE, "malformed catalog");
|
||||
let paths = vec![
|
||||
home.path("config.toml"),
|
||||
home.path("auth.json"),
|
||||
home.path(CODEX_MODEL_CATALOG_FILE),
|
||||
home.path(CODEX_NATIVE_OAUTH_STATE_FILE),
|
||||
];
|
||||
let before = config_images(&paths).unwrap();
|
||||
assert!(apply_cpa(&home, false).is_err());
|
||||
assert_eq!(config_images(&paths).unwrap(), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_cpa_update_preserves_official_routing_and_login() {
|
||||
let home = TestHome::new();
|
||||
home.write("config.toml", CPA_CONFIG);
|
||||
home.write("auth.json", OAUTH);
|
||||
switch_codex_native_oauth(&home.0, true).unwrap();
|
||||
let mut paths = config_paths("codex", &home.0).unwrap();
|
||||
paths.push(home.path(CODEX_NATIVE_OAUTH_STATE_FILE));
|
||||
let before = config_images(&paths).unwrap();
|
||||
let result = apply_agent_configuration_with_oauth(
|
||||
AgentClient::Codex,
|
||||
&home.0,
|
||||
8317,
|
||||
"test-api-key",
|
||||
"unavailable-model",
|
||||
AgentConfigurationOptions {
|
||||
models: &[],
|
||||
codex_catalog: Some(r#"{"models":[{"slug":"other-model"}]}"#),
|
||||
oauth_configuration: false,
|
||||
claude_code_model_mappings: None,
|
||||
claude_desktop_model_mappings: None,
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert_eq!(config_images(&paths).unwrap(), before);
|
||||
assert!(codex_native_oauth_enabled(&home.0).unwrap());
|
||||
assert!(home.config().get("model_catalog_json").is_none());
|
||||
}
|
||||
@@ -1351,6 +1351,7 @@ pub(crate) fn sync_codex_model_catalog_if_configured(
|
||||
let _guard = AGENT_CONFIG_FILE_LOCK
|
||||
.lock()
|
||||
.map_err(|_| "智能体配置文件锁已损坏".to_string())?;
|
||||
if codex_native_oauth_enabled(home)? { return Ok(false); }
|
||||
let before = config_images(&config_paths(client.id(), home)?)?;
|
||||
validate_config_images(&before)?;
|
||||
let (configured, current_model, _) =
|
||||
@@ -1447,6 +1448,9 @@ pub(crate) fn apply_agent_configuration_with_oauth(
|
||||
model: &str,
|
||||
options: AgentConfigurationOptions<'_>,
|
||||
) -> Result<AgentConfigActionResult, String> {
|
||||
if client == AgentClient::Codex {
|
||||
return apply_codex_cpa_configuration(home, port, api_key, model, options);
|
||||
}
|
||||
if client == AgentClient::DeepSeekHarness {
|
||||
return apply_deepseek_harness_configuration(home, port, api_key, model, options.models);
|
||||
}
|
||||
|
||||
@@ -122,6 +122,7 @@ async fn prepare_template_plan(
|
||||
claude_code_model_mappings: Option<ClaudeDesktopModelMappings>,
|
||||
claude_desktop_model_mappings: Option<ClaudeDesktopModelMappings>,
|
||||
) -> Result<TemplatePlan, String> {
|
||||
if client == "codex" { ensure_codex_cpa_mode(home)?; }
|
||||
let paths = config_paths(client, home)?;
|
||||
let api_key = effective_agent_api_key(config);
|
||||
let (model, mappings, before, after) = if client == PI_AGENT_ID {
|
||||
|
||||
@@ -1042,6 +1042,7 @@ struct AgentConfigStatus {
|
||||
connection_state: String,
|
||||
current_model: Option<String>,
|
||||
oauth_configuration: bool,
|
||||
codex_native_oauth: bool,
|
||||
modification_enabled: bool,
|
||||
modification_state: String,
|
||||
backup_available: bool,
|
||||
@@ -2534,6 +2535,8 @@ fn main() {
|
||||
delete_speed_alias,
|
||||
apply_agent_config,
|
||||
clear_codex_config,
|
||||
restore_codex_official_config,
|
||||
close_codex_config_modification,
|
||||
set_agent_config_enabled,
|
||||
update_agent_config,
|
||||
launch_agent,
|
||||
|
||||
+14
-6
@@ -1059,7 +1059,12 @@ export const jaOverrides = {
|
||||
"agents.modify.connectionUnknown": "現在の接続設定:判定できません。設定を確認してください",
|
||||
"agents.modify.description": "選択したモデルと認証方式を書き込み、無関係な設定を保持します。",
|
||||
"agents.modify.authApiKeyHint": "CPA の API を使用して接続し、より安定した接続状態を維持します。",
|
||||
"agents.modify.authOAuthHint": "CPA 経由で接続し、アカウントのログイン状態を保持して Codex の全サービスを利用できます。事前に Codex でアカウントにログインしてください。",
|
||||
"agents.nativeOAuth.status": "CPA 未接続",
|
||||
"agents.nativeOAuth.restore": "接続設定を解除",
|
||||
"agents.nativeOAuth.restoreHint": "CPA の接続設定を解除し、Codex で ChatGPT にログインできるようにします。Codex を再起動してログインした後、基本設定で OAuth を選び、設定を更新してください。",
|
||||
"agents.nativeOAuth.activeHint": "現在 CPA に接続していません。ログイン後に OAuth を選んで設定を更新すると、CPA に再接続できます。",
|
||||
"agents.nativeOAuth.restored": "CPA の接続設定を解除しました。Codex を再起動して ChatGPT にログインし、基本設定で OAuth を選んで設定を更新してください。",
|
||||
"agents.modify.authOAuthHint": "アカウントのログイン状態を保持して CPA 経由で接続します。未ログインの状態で API から切り替える場合は、設定管理で「接続設定を解除」をクリックし、Codex を再起動して ChatGPT にログインしてから選択してください。",
|
||||
"agents.modify.authHelpLabel": "説明",
|
||||
"agents.modify.authHelpShow": "選び方",
|
||||
"agents.modify.authHelpHide": "説明を閉じる",
|
||||
@@ -1097,16 +1102,19 @@ export const jaOverrides = {
|
||||
'agents.claudeMapping.context1m': '1M',
|
||||
'agents.claudeMapping.context1mHint': '1M コンテキストとして設定します。選択した上流モデルが対応していることを確認してください。',
|
||||
'agents.oauthLoginRequired.title': 'Codex へのログインが必要です',
|
||||
'agents.oauthLoginRequired.enableDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定方式を有効にできません。Codex アプリで ChatGPT アカウントにログインしてください。Codex アプリでログインを開始できない場合は、先に',
|
||||
'agents.oauthLoginRequired.enableManagementConnector': 'の',
|
||||
'agents.oauthLoginRequired.enableClearConfiguration': '設定を消去',
|
||||
'agents.oauthLoginRequired.enableDescriptionSuffix': 'を使用してください。',
|
||||
'agents.oauthLoginRequired.applyDescription': '有効な Codex のログイン認証情報が見つからないため、OAuth 設定を適用できません。Codex で ChatGPT アカウントにログインしてから、設定を適用してください。',
|
||||
"agents.oauthLoginRequired.enableDescription": "利用可能な Codex のログイン情報が見つかりません。API から OAuth に切り替えるには、",
|
||||
"agents.oauthLoginRequired.enableManagementConnector": "で",
|
||||
"agents.oauthLoginRequired.enableClearConfiguration": "接続設定を解除",
|
||||
"agents.oauthLoginRequired.enableDescriptionSuffix": "をクリックしてください。Codex を再起動して ChatGPT にログインした後、基本設定で OAuth を選んで設定を更新してください。",
|
||||
"agents.oauthLoginRequired.applyDescription": "利用可能な Codex のログイン情報が見つかりません。設定管理で「接続設定を解除」をクリックし、Codex を再起動して ChatGPT にログインした後、基本設定で OAuth を選んで設定を更新してください。",
|
||||
'agents.oauthLoginRequired.launchDescription': '有効なログイン認証情報が見つからないため Codex を起動できません。先に Codex アプリで ChatGPT アカウントにログインしてください。',
|
||||
"agents.oauthLoginRequired.openManagement": "設定管理を開く",
|
||||
'agents.oauthLoginRequired.confirm': '了解しました',
|
||||
'agents.modify.apply': '設定変更を適用',
|
||||
'agents.modify.update': '設定を更新',
|
||||
'agents.modify.close': '設定変更を終了',
|
||||
'agents.modify.closeHint': 'CPA 設定を解除し、保存されていれば接続前の設定とログイン情報を復元します。',
|
||||
'agents.modify.closed': 'CPA 設定を解除しました。Codex を再起動し、必要に応じてログインしてください。',
|
||||
"agents.modify.default": "ezcpa テンプレートを適用",
|
||||
'agents.modify.clear': '設定を消去',
|
||||
"agents.modify.invalid": "通常の更新は停止しています。設定ファイルを修正するか、「設定管理」の「ezcpa テンプレートを適用」で接続設定を再生成してください。",
|
||||
|
||||
+15
-7
@@ -1077,7 +1077,12 @@ export const en: Record<MessageKey, string> = {
|
||||
"agents.modify.connectionUnknown": "Current integration: unknown; check configuration",
|
||||
"agents.modify.description": "Write the selected model and authentication settings while preserving unrelated settings.",
|
||||
"agents.modify.authApiKeyHint": "Connect using the CPA API for a more stable connection.",
|
||||
"agents.modify.authOAuthHint": "Connect through CPA while retaining your account sign-in state and access to the full Codex service. Sign in to your account in Codex first.",
|
||||
"agents.nativeOAuth.status": "CPA disconnected",
|
||||
"agents.nativeOAuth.restore": "Clear integration",
|
||||
"agents.nativeOAuth.restoreHint": "Clear the CPA integration so Codex can use ChatGPT sign-in. Restart Codex and sign in, then return to Basic configuration, select OAuth, and update the configuration.",
|
||||
"agents.nativeOAuth.activeHint": "CPA is disconnected. After signing in, select OAuth and update the configuration to reconnect through CPA.",
|
||||
"agents.nativeOAuth.restored": "CPA integration cleared. Restart Codex and sign in to ChatGPT, then return to Basic configuration, select OAuth, and update the configuration.",
|
||||
"agents.modify.authOAuthHint": "Connect through CPA while retaining your account sign-in. When switching from API mode without sign-in credentials, use Clear integration in Configuration management, restart Codex, and sign in to ChatGPT before selecting this mode.",
|
||||
"agents.modify.authHelpLabel": "Details",
|
||||
"agents.modify.authHelpShow": "How to choose",
|
||||
"agents.modify.authHelpHide": "Hide details",
|
||||
@@ -1097,16 +1102,19 @@ export const en: Record<MessageKey, string> = {
|
||||
"agents.modify.title": "Write {name} Configuration",
|
||||
'agents.modify.oauthConfiguration': 'OAuth sign-in configuration mode',
|
||||
'agents.oauthLoginRequired.title': 'Codex sign-in required',
|
||||
'agents.oauthLoginRequired.enableDescription': 'No valid Codex sign-in credentials were found, so OAuth configuration mode cannot be enabled. Sign in to your ChatGPT account in the Codex app. If the Codex app cannot trigger sign-in, first open ',
|
||||
'agents.oauthLoginRequired.enableManagementConnector': ', then use ',
|
||||
'agents.oauthLoginRequired.enableClearConfiguration': 'Clear Configuration',
|
||||
'agents.oauthLoginRequired.enableDescriptionSuffix': '.',
|
||||
'agents.oauthLoginRequired.applyDescription': 'OAuth configuration cannot be applied because no valid Codex sign-in credentials were found. Sign in to your ChatGPT account in Codex, then apply the configuration.',
|
||||
"agents.oauthLoginRequired.enableDescription": "No usable Codex sign-in credentials were found. To switch from API mode to OAuth, open ",
|
||||
"agents.oauthLoginRequired.enableManagementConnector": " and click ",
|
||||
"agents.oauthLoginRequired.enableClearConfiguration": "Clear integration",
|
||||
"agents.oauthLoginRequired.enableDescriptionSuffix": ". Restart Codex and sign in to ChatGPT, then return to Basic configuration, select OAuth, and update the configuration.",
|
||||
"agents.oauthLoginRequired.applyDescription": "No usable Codex sign-in credentials were found. Open Configuration management and click Clear integration. Restart Codex and sign in to ChatGPT, then return to Basic configuration, select OAuth, and update the configuration.",
|
||||
'agents.oauthLoginRequired.launchDescription': 'Codex cannot be started because no valid sign-in credentials were found. Sign in to your ChatGPT account in the Codex app first.',
|
||||
"agents.oauthLoginRequired.openManagement": "Open configuration management",
|
||||
'agents.oauthLoginRequired.confirm': 'Got it',
|
||||
'agents.modify.apply': 'Apply Configuration Changes',
|
||||
'agents.modify.update': 'Update Configuration',
|
||||
'agents.modify.close': 'Close Configuration Changes',
|
||||
'agents.modify.close': 'Close configuration',
|
||||
'agents.modify.closeHint': 'Remove CPA configuration and restore the previous settings and sign-in credentials when available.',
|
||||
'agents.modify.closed': 'CPA configuration disabled. Restart Codex and sign in if prompted.',
|
||||
"agents.modify.default": "Apply ezcpa template",
|
||||
'agents.modify.clear': 'Clear Configuration',
|
||||
"agents.modify.invalid": "Normal updates are stopped. Fix the configuration file, or use Apply ezcpa template in Configuration management to regenerate the connection configuration.",
|
||||
|
||||
@@ -1076,7 +1076,12 @@ export const zhCN = {
|
||||
"agents.modify.connectionUnknown": "当前接入:无法判断,请检查配置",
|
||||
"agents.modify.description": "将所选模型和认证方式写入本机配置,保留无关设置。",
|
||||
"agents.modify.authApiKeyHint": "使用 CPA 的API接入,状态更稳定。",
|
||||
"agents.modify.authOAuthHint": "仍通过 CPA 接入,保留账号登录状态,能使用完整的Codex服务,需在Codex中提前登录账号。",
|
||||
"agents.nativeOAuth.status": "未接入 CPA",
|
||||
"agents.nativeOAuth.restore": "清除接入",
|
||||
"agents.nativeOAuth.restoreHint": "清除 CPA 接入,让 Codex 使用 ChatGPT 登录。请重启 Codex 并登录账号,再回到基础配置选择 OAuth 登录方式并更新配置。",
|
||||
"agents.nativeOAuth.activeHint": "当前未接入 CPA;登录后选择 OAuth 登录方式,更新配置将重新接入 CPA。",
|
||||
"agents.nativeOAuth.restored": "已清除 CPA 接入。请重启 Codex 并登录 ChatGPT,再回到基础配置选择 OAuth 登录方式并更新配置。",
|
||||
"agents.modify.authOAuthHint": "通过 CPA 接入并保留账号登录状态。从 API 切换且未登录时,先在配置管理中点击“清除接入”,重启 Codex 并登录 ChatGPT,再选择此方式。",
|
||||
"agents.modify.authHelpLabel": "说明",
|
||||
"agents.modify.authHelpShow": "如何选择",
|
||||
"agents.modify.authHelpHide": "收起说明",
|
||||
@@ -1096,16 +1101,19 @@ export const zhCN = {
|
||||
"agents.modify.title": "写入 {name} 配置",
|
||||
'agents.modify.oauthConfiguration': 'OAuth登录配置方式',
|
||||
'agents.oauthLoginRequired.title': '需要登录 Codex',
|
||||
'agents.oauthLoginRequired.enableDescription': '未检测到有效的 Codex 登录凭据,无法开启 OAuth 配置模式。请先在 Codex 应用中登录 ChatGPT 账号,如果 Codex 应用无法触发登录,请先在',
|
||||
'agents.oauthLoginRequired.enableManagementConnector': '中',
|
||||
'agents.oauthLoginRequired.enableClearConfiguration': '清空配置',
|
||||
'agents.oauthLoginRequired.enableDescriptionSuffix': '。',
|
||||
'agents.oauthLoginRequired.applyDescription': '无法应用 OAuth 配置:未检测到有效的 Codex 登录凭据。请先在 Codex 中登录 ChatGPT 账号,然后再应用配置。',
|
||||
"agents.oauthLoginRequired.enableDescription": "未检测到可用的 Codex 登录凭据。从 API 切换到 OAuth 登录方式时,请先在",
|
||||
"agents.oauthLoginRequired.enableManagementConnector": "中点击",
|
||||
"agents.oauthLoginRequired.enableClearConfiguration": "清除接入",
|
||||
"agents.oauthLoginRequired.enableDescriptionSuffix": ",然后重启 Codex 并登录 ChatGPT。登录完成后,返回基础配置选择 OAuth 登录方式并更新配置。",
|
||||
"agents.oauthLoginRequired.applyDescription": "未检测到可用的 Codex 登录凭据。请前往“配置管理”点击“清除接入”,重启 Codex 并登录 ChatGPT,再返回基础配置选择 OAuth 登录方式并更新配置。",
|
||||
'agents.oauthLoginRequired.launchDescription': '无法启动 Codex:未检测到有效的登录凭据。请先在 Codex 应用中登录 ChatGPT 账号。',
|
||||
"agents.oauthLoginRequired.openManagement": "前往配置管理",
|
||||
'agents.oauthLoginRequired.confirm': '我知道了',
|
||||
'agents.modify.apply': '应用配置修改',
|
||||
'agents.modify.update': '更新配置',
|
||||
'agents.modify.close': '关闭配置修改',
|
||||
'agents.modify.closeHint': '撤回 CPA 配置,优先恢复接入前的设置和登录凭据。',
|
||||
'agents.modify.closed': '已关闭 CPA 配置,请重启 Codex;如有提示,请登录账号。',
|
||||
"agents.modify.default": "应用 ezcpa 模板",
|
||||
'agents.modify.clear': '清空配置',
|
||||
"agents.modify.invalid": "普通更新已停止。请修复配置文件,或在“配置管理”中通过“应用 ezcpa 模板”重新生成接入配置。",
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const alwaysAvailablePages = new Set(['easy', 'home', 'versions', 'config', 'usage-records']);
|
||||
const alwaysAvailablePages = new Set(['easy', 'home', 'versions', 'config', 'usage-records', 'agents']);
|
||||
|
||||
export function isAlwaysAvailablePage(pageId: string) {
|
||||
return alwaysAvailablePages.has(pageId);
|
||||
|
||||
@@ -76,12 +76,13 @@ export function AgentConfigurationFeedback({ pending, description, status = '' }
|
||||
|
||||
export function AgentConfigManagementPanel({
|
||||
pi, codex, busyAction, canTemplate, canUpdatePi, canUninstallPi, pluginInstalled, pluginVersion, updateLabel,
|
||||
onBackup, onRestore, onTemplate, onClear, onUpdatePi, onUninstallPi,
|
||||
onBackup, onRestore, onTemplate, onClear, onUpdatePi, onUninstallPi, canClearIntegration, onClearIntegration,
|
||||
}: {
|
||||
pi: boolean; codex: boolean; busyAction: string | null; canTemplate: boolean;
|
||||
canUpdatePi: boolean; canUninstallPi: boolean; pluginInstalled: boolean; pluginVersion: string | null; updateLabel: string;
|
||||
onBackup: () => void; onRestore: () => void; onTemplate: () => void; onClear: () => void;
|
||||
onUpdatePi: () => void; onUninstallPi: () => void;
|
||||
canClearIntegration: boolean; onClearIntegration: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const busy = busyAction !== null;
|
||||
@@ -101,6 +102,16 @@ export function AgentConfigManagementPanel({
|
||||
<button type="button" className="secondary-button" onClick={onTemplate} disabled={busy || !canTemplate}>{t('agents.modify.default')}</button>
|
||||
</div>
|
||||
</section> : null}
|
||||
{codex ? <section className="agent-management-row agent-management-clear-integration">
|
||||
<div><h3>{t('agents.nativeOAuth.restore')}</h3><p>{t('agents.nativeOAuth.restoreHint')}</p></div>
|
||||
<div className="agent-management-actions">
|
||||
<button type="button" id="agent-clear-integration" className="secondary-button agent-clear-integration"
|
||||
onClick={onClearIntegration} disabled={busy || !canClearIntegration}>
|
||||
{busyAction === 'native-oauth' ? <LoaderCircle size={16} className="spin" /> : null}
|
||||
{t('agents.nativeOAuth.restore')}
|
||||
</button>
|
||||
</div>
|
||||
</section> : null}
|
||||
{codex ? <section className="agent-management-row">
|
||||
<div><h3>{t('agents.modify.clear')}</h3><p>{t('agents.management.clearDescription')}</p></div>
|
||||
<div className="agent-management-actions">
|
||||
|
||||
+84
-11
@@ -102,6 +102,7 @@ type AgentConfigStatus = {
|
||||
configurationSynchronized: boolean;
|
||||
currentModel: string | null;
|
||||
oauthConfiguration: boolean;
|
||||
codexNativeOauth: boolean;
|
||||
modificationEnabled: boolean;
|
||||
modificationState: AgentModificationState;
|
||||
backupAvailable: boolean;
|
||||
@@ -435,6 +436,7 @@ const listStatusText = (status: AgentConfigStatus | undefined) => {
|
||||
: translate(locale, 'agents.list.pluginNotInstalled');
|
||||
}
|
||||
if (status.modificationState === 'invalid') return translate(locale, 'agents.status.invalid');
|
||||
if (status.id === 'codex' && status.codexNativeOauth) return translate(locale, 'agents.nativeOAuth.status');
|
||||
if (status.modificationState === 'applied') return translate(locale, 'agents.list.modified', { model: status.appliedModel ?? '—' });
|
||||
return status.version
|
||||
? translate(locale, 'agents.list.installedVersion', { version: status.version })
|
||||
@@ -736,7 +738,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modelLoading, setModelLoading] = useState(false);
|
||||
const [busyAction, setBusyAction] = useState<
|
||||
'backup' | 'apply' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | 'restart-deepseek' | null
|
||||
'backup' | 'apply' | 'close-config' | 'default' | 'clear' | 'install-pi' | 'update-pi' | 'repair-pi' | 'uninstall-pi' | 'oauth-check' | 'native-oauth' | 'directory' | 'launch' | 'launch-cli' | 'launch-app' | 'restart-app' | 'stop-deepseek' | 'restart-deepseek' | null
|
||||
>(null);
|
||||
const busy = busyAction !== null;
|
||||
const [detectionError, setDetectionError] = useState('');
|
||||
@@ -864,7 +866,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
const preferredModel = statuses.find((status) => status.id === selected)?.currentModel ?? '';
|
||||
const status = statuses.find((status) => status.id === selected);
|
||||
const preferredModel = status?.currentModel ?? '';
|
||||
void loadModels(selected, preferredModel);
|
||||
}, [loadModels, loading, selected]);
|
||||
|
||||
@@ -925,6 +928,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
const activeDefinition = agentDefinitions.find((agent) => agent.id === selected)
|
||||
?? agentDefinitions[0];
|
||||
const activeStatus = statuses.find((status) => status.id === selected) ?? null;
|
||||
const nativeOauth = selected === 'codex' && Boolean(activeStatus?.codexNativeOauth);
|
||||
const oauthConfiguration = oauthConfigurationDraft
|
||||
?? activeStatus?.oauthConfiguration
|
||||
?? false;
|
||||
@@ -1027,10 +1031,13 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
]);
|
||||
|
||||
const connectionState = activeStatus?.connectionState ?? 'invalid';
|
||||
const configurationActionLabel = t(connectionState === 'not-configured' ? 'agents.modify.connect'
|
||||
const appliedModel = activeStatus?.currentModel ?? activeStatus?.appliedModel ?? '';
|
||||
const canCloseCodexConfiguration = selected === 'codex' && !nativeOauth
|
||||
&& connectionState === 'configured';
|
||||
const configurationActionLabel = t(nativeOauth ? 'agents.modify.update'
|
||||
: connectionState === 'not-configured' ? selected === 'codex' ? 'agents.modify.apply' : 'agents.modify.connect'
|
||||
: connectionState === 'needs-update' ? 'agents.modify.repair' : 'agents.modify.update');
|
||||
const configurationWriteBlocked = loading || !activeStatus || connectionState === 'invalid';
|
||||
const appliedModel = activeStatus?.currentModel ?? activeStatus?.appliedModel ?? '';
|
||||
const formValues: AgentFormValues = {
|
||||
model: isClaudeModelMappingClient ? '' : selectedModel,
|
||||
oauthConfiguration: selected === 'codex' && oauthConfiguration,
|
||||
@@ -1108,6 +1115,7 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
: t('agents.model.firstSelection', { count: models.length }));
|
||||
const modificationDescription = activeStatus?.modificationState === 'invalid'
|
||||
? t('agents.modify.invalid')
|
||||
: nativeOauth ? t('agents.nativeOAuth.activeHint')
|
||||
: selected === 'zcode'
|
||||
? t('agents.modify.zcodeRestart')
|
||||
: '';
|
||||
@@ -1273,6 +1281,43 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
return false;
|
||||
};
|
||||
|
||||
const clearCodexIntegration = async () => {
|
||||
setBusyAction('native-oauth');
|
||||
setConfigurationError('');
|
||||
setConfigurationNotice('');
|
||||
setClearNotice('');
|
||||
try {
|
||||
await invoke('restore_codex_official_config');
|
||||
setModelError('');
|
||||
setModelSelectionError('');
|
||||
setStatuses((current) => current.map((status) => status.id === 'codex'
|
||||
? { ...status, codexNativeOauth: true } : status));
|
||||
await reloadStatusesAfterAction();
|
||||
setConfigurationNotice(t('agents.nativeOAuth.restored'));
|
||||
} catch (cause) {
|
||||
setConfigurationError(String(cause));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const closeConfigurationChanges = async () => {
|
||||
setBusyAction('close-config');
|
||||
setConfigurationError('');
|
||||
setConfigurationNotice('');
|
||||
setClearNotice('');
|
||||
try {
|
||||
await invoke<AgentConfigActionResult>('close_codex_config_modification');
|
||||
clearPendingChanges();
|
||||
await reloadStatusesAfterAction();
|
||||
setConfigurationNotice(t('agents.modify.closed'));
|
||||
} catch (cause) {
|
||||
setConfigurationError(String(cause));
|
||||
} finally {
|
||||
setBusyAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const changeOauthConfiguration = async (enabled: boolean) => {
|
||||
setConfigurationNotice('');
|
||||
setClearNotice('');
|
||||
@@ -1659,6 +1704,12 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
setClearConfirmOpen(false);
|
||||
};
|
||||
|
||||
const openClearIntegrationGuide = () => {
|
||||
setOauthLoginRequiredAction(null);
|
||||
setActiveSubpage('management');
|
||||
requestAnimationFrame(() => document.getElementById('agent-clear-integration')?.focus());
|
||||
};
|
||||
|
||||
const availableSubpages = agentSubpages.filter(
|
||||
(subpage) => (!subpage.clients || subpage.clients.includes(selected)) && (!embedded || subpage.id !== 'sessions'),
|
||||
);
|
||||
@@ -1687,11 +1738,21 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const closeConfigurationButton = canCloseCodexConfiguration ? (
|
||||
<button type="button" className="secondary-button agent-close-configuration"
|
||||
title={t('agents.modify.closeHint')}
|
||||
disabled={busy || configurationWriteBlocked}
|
||||
onClick={() => void closeConfigurationChanges()}>
|
||||
{busyAction === 'close-config' ? <LoaderCircle size={16} className="spin" /> : null}
|
||||
{t('agents.modify.close')}
|
||||
</button>
|
||||
) : null;
|
||||
|
||||
const configurationFeedback = <AgentConfigurationFeedback pending={hasPendingChanges}
|
||||
status={modelLoading || !activeStatus ? t('agents.modify.checking')
|
||||
: activeStatus.modificationState === 'invalid' ? t('agents.modify.invalidState') : ''}
|
||||
description={modificationDescription} />;
|
||||
const configurationErrorMessage = configurationError || modelSelectionError || modelError;
|
||||
const configurationErrorMessage = configurationError || (!nativeOauth ? modelSelectionError || modelError : '');
|
||||
|
||||
return (
|
||||
<section className={`page management-page agents-page${embedded ? ' agents-page-embedded' : ''}`}>
|
||||
@@ -1810,7 +1871,8 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
|
||||
<div className="agent-save-bar">
|
||||
{configurationFeedback}
|
||||
<div className="agent-save-actions">
|
||||
<div className={`agent-save-actions${selected === 'codex' ? ' agent-codex-save-actions' : ''}`}>
|
||||
{closeConfigurationButton}
|
||||
<button
|
||||
type="button"
|
||||
className="primary-button"
|
||||
@@ -2116,8 +2178,11 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
<div className="agent-save-bar">
|
||||
{configurationFeedback}
|
||||
{selected === 'claude-desktop' && !activeStatus?.claudeDesktopModelMappings ? <p className="agent-inline-message warning">{t('agents.backup.mappingRequired')}</p> : null}
|
||||
<div className="agent-save-actions">
|
||||
<button type="button" className="primary-button" onClick={applySelectedConfiguration} disabled={busy || !canEnable || configurationWriteBlocked}>
|
||||
<div className={`agent-save-actions${selected === 'codex' ? ' agent-codex-save-actions' : ''}`}>
|
||||
{closeConfigurationButton}
|
||||
<button type="button" className="primary-button" onClick={applySelectedConfiguration}
|
||||
disabled={busy || !canEnable || configurationWriteBlocked}
|
||||
>
|
||||
{['apply', 'install-pi', 'repair-pi'].includes(busyAction ?? '') ? <LoaderCircle size={16} className="spin" /> : null}
|
||||
{isPiClient && !activeStatus?.pluginInstalled ? t('agents.pi.install') : configurationActionLabel}
|
||||
</button>
|
||||
@@ -2140,12 +2205,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
{activeSubpage === 'management' ? (
|
||||
<div id="agent-subpage-panel-management" role="tabpanel" aria-labelledby="agent-subpage-tab-management">
|
||||
<AgentConfigManagementPanel pi={isPiClient} codex={selected === 'codex'} busyAction={busyAction}
|
||||
canTemplate={canEnable} canUpdatePi={canEnable && !configurationWriteBlocked && Boolean(activeStatus?.pluginInstalled)}
|
||||
canTemplate={canEnable && !nativeOauth} canUpdatePi={canEnable && !configurationWriteBlocked && Boolean(activeStatus?.pluginInstalled)}
|
||||
canUninstallPi={launchEnabled && Boolean(activeStatus?.pluginInstalled)}
|
||||
pluginInstalled={Boolean(activeStatus?.pluginInstalled)} pluginVersion={activeStatus?.pluginVersion ?? null}
|
||||
updateLabel={piPluginUpdateAvailable ? piPluginUpdateTitle ?? '' : ''}
|
||||
onBackup={() => void createManualBackup()} onRestore={() => setBackupsOpen(true)}
|
||||
onTemplate={() => void openDefaultConfirmation()} onClear={openClearConfirmation}
|
||||
canClearIntegration={!loading && Boolean(activeStatus?.configValid && activeStatus.supportedPlatform)}
|
||||
onClearIntegration={() => void clearCodexIntegration()}
|
||||
onUpdatePi={() => void updatePiProvider()}
|
||||
onUninstallPi={() => void uninstallPiProvider()} />
|
||||
</div>
|
||||
@@ -2470,8 +2537,14 @@ export function AgentsPage({ embedded = false, onConfigurationApplied }: AgentsP
|
||||
<div><AlertTriangle size={19} /><h2 id="agent-oauth-login-required-title">{t('agents.oauthLoginRequired.title')}</h2></div>
|
||||
</div>
|
||||
<p>{oauthLoginRequiredDescription}</p>
|
||||
<div className="config-dialog-actions single-action">
|
||||
<button type="button" className="primary-button" onClick={() => setOauthLoginRequiredAction(null)}>{t('agents.oauthLoginRequired.confirm')}</button>
|
||||
<div className={`config-dialog-actions${oauthLoginRequiredAction === 'launch' ? ' single-action' : ''}`}>
|
||||
<button type="button" className={oauthLoginRequiredAction === 'launch' ? 'primary-button' : 'secondary-button'}
|
||||
onClick={() => setOauthLoginRequiredAction(null)}>{t('agents.oauthLoginRequired.confirm')}</button>
|
||||
{oauthLoginRequiredAction !== 'launch' ? (
|
||||
<button type="button" className="primary-button" onClick={openClearIntegrationGuide}>
|
||||
{t('agents.oauthLoginRequired.openManagement')}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -14468,3 +14468,18 @@ body.table-col-resizing * {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-codex-save-actions {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.agent-codex-save-actions .primary-button {
|
||||
width: 128px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.agent-codex-save-actions button {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
padding-inline: 12px;
|
||||
}
|
||||
@@ -15,10 +15,10 @@ const assert = require('node:assert/strict');
|
||||
const choose = async () => { await manage(); await page.getByRole('button', { name: '恢复备份', exact: true }).click(); await page.locator('.agent-backup-columns nav button').first().click(); };
|
||||
for (const query of ['fresh', 'embedded&fresh']) {
|
||||
await open(query);
|
||||
await page.getByRole('button', { name: '一键接入', exact: true }).click();
|
||||
await page.getByRole('button', { name: '应用配置修改', exact: true }).click();
|
||||
await page.getByText('配置已更新。', { exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: '更新配置', exact: true }).click();
|
||||
await page.getByText('配置已是最新,无需写入。', { exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: '关闭配置修改', exact: true }).waitFor();
|
||||
assert.equal((await calls('close_codex_config_modification')).length, 0);
|
||||
assert.equal((await calls('create_agent_config_backup')).length, 0);
|
||||
await backup();
|
||||
assert.deepEqual((await calls('create_agent_config_backup'))[0].args, { client: 'codex' });
|
||||
|
||||
@@ -15,7 +15,7 @@ const base = 'http://127.0.0.1:1421';
|
||||
await page.waitForFunction(() => window.fixtureCalls.some(call => call.cmd === 'get_agent_models')
|
||||
&& !document.querySelector('.agent-model-trigger')?.disabled);
|
||||
};
|
||||
const positions = () => page.locator('.agent-save-bar, .agent-save-actions button, .agent-run-controls').evaluateAll(nodes => nodes.map(node => {
|
||||
const positions = () => page.locator('.agent-save-bar, .agent-save-actions .primary-button, .agent-run-controls').evaluateAll(nodes => nodes.map(node => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return [rect.x + scrollX, rect.y + scrollY, rect.width, rect.height];
|
||||
}));
|
||||
@@ -24,7 +24,7 @@ const base = 'http://127.0.0.1:1421';
|
||||
assert.ok(geometry[0][3] <= 60, 'The save bar occupies only a button-height row plus padding');
|
||||
assert.ok(geometry[2][1] - geometry[0][1] - geometry[0][3] <= 20, 'No blank feedback region separates save and run controls');
|
||||
assert.equal(await page.locator('.agent-shared-feedback').count(), 0, 'Empty state feedback does not reserve space');
|
||||
assert.ok(await page.locator('.agent-save-actions button').evaluate(node => node.getBoundingClientRect().width <= 160), 'Save button stays compact in both entry points');
|
||||
assert.ok(await page.locator('.agent-save-actions .primary-button').evaluate(node => node.getBoundingClientRect().width <= 160), 'Save button stays compact in both entry points');
|
||||
return geometry;
|
||||
};
|
||||
fs.mkdirSync('misc', { recursive: true });
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const base = 'http://127.0.0.1:1421';
|
||||
(async () => {
|
||||
const browser = await chromium.launch({ channel: 'msedge', headless: true, args: ['--no-proxy-server'] });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
page.setDefaultNavigationTimeout(30000);
|
||||
await page.route('**/*', route => route.request().url().startsWith(base + '/') ? route.continue() : route.abort());
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error)));
|
||||
const restore = () => page.getByRole('button', { name: '清除接入', exact: true });
|
||||
const update = () => page.locator('.agent-save-actions .primary-button');
|
||||
const core = () => page.getByRole('tab', { name: '基础配置', exact: true }).click();
|
||||
const manage = () => page.getByRole('tab', { name: '配置管理', exact: true }).click();
|
||||
const restoreOfficial = async () => {
|
||||
await manage();
|
||||
assert.equal(await page.locator('.agent-management-clear-integration .agent-clear-integration').count(), 1);
|
||||
await restore().click();
|
||||
await page.waitForFunction(() => !document.querySelector('.agent-clear-integration').disabled);
|
||||
assert.equal(await page.getByRole('tab', { name: '配置管理', exact: true }).getAttribute('aria-selected'), 'true');
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||
await core();
|
||||
};
|
||||
const open = async query => {
|
||||
await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&client=codex&${query}`, { waitUntil: 'domcontentloaded' });
|
||||
await update().waitFor();
|
||||
await page.waitForFunction(() => {
|
||||
const button = document.querySelector('.agents-page .management-header button');
|
||||
return window.fixtureCalls.some(call => call.cmd === 'get_agent_models') && button && !button.disabled;
|
||||
});
|
||||
};
|
||||
const waitMode = async enabled => {
|
||||
await page.waitForFunction(value => {
|
||||
const button = document.querySelector('.agent-save-actions .primary-button');
|
||||
const status = document.querySelector('.agent-client-list button.active small');
|
||||
return button && (status?.textContent === '未接入 CPA') === value;
|
||||
}, enabled);
|
||||
};
|
||||
const assertButtonOrder = async (applied = true) => {
|
||||
const positions = await page.locator('.agent-save-actions button').evaluateAll(nodes => nodes.map(node => {
|
||||
const rect = node.getBoundingClientRect();
|
||||
return { x: rect.x, y: rect.y, width: rect.width };
|
||||
}));
|
||||
assert.equal(positions.length, applied ? 2 : 1);
|
||||
if (applied) {
|
||||
const [closePosition, updatePosition] = positions;
|
||||
assert.ok(closePosition.x + closePosition.width <= updatePosition.x + 1, 'Close is left of Update');
|
||||
assert.ok(Math.abs(closePosition.y - updatePosition.y) < 1, 'Close and Update share a row');
|
||||
}
|
||||
assert.equal(await restore().count(), 0, 'Official sign-in is only available in configuration management');
|
||||
assert.equal(await page.locator('.agent-native-oauth, [role="switch"]').count(), 0, 'The old mode switch is gone');
|
||||
};
|
||||
fs.mkdirSync('misc', { recursive: true });
|
||||
for (const width of [1280, 360]) for (const embedded of [false, true]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await open(embedded ? 'embedded' : '');
|
||||
await assertButtonOrder();
|
||||
await restoreOfficial();
|
||||
await waitMode(true);
|
||||
assert.equal(await page.locator('.agent-close-configuration').count(), 0);
|
||||
assert.equal(await page.locator('.agent-model-trigger').count(), 1, 'CPA models remain selectable for reconnect');
|
||||
await assertButtonOrder(false);
|
||||
assert.match(await page.locator('.agent-save-feedback').innerText(), /更新配置将重新接入 CPA/);
|
||||
const calls = await page.evaluate(() => window.fixtureCalls);
|
||||
assert.equal(calls.filter(call => ['check_codex_oauth_login', 'update_agent_config'].includes(call.cmd)).length, 0);
|
||||
assert.equal(calls.filter(call => call.cmd === 'restore_codex_official_config').length, 1);
|
||||
await page.evaluate(() => window.fixtureRemount());
|
||||
await waitMode(true);
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||
await manage();
|
||||
await restore().waitFor();
|
||||
await page.evaluate(() => window.fixtureRemount());
|
||||
await restore().waitFor();
|
||||
assert.equal(await page.getByRole('tab', { name: '配置管理', exact: true }).getAttribute('aria-selected'), 'true');
|
||||
if (width === 1280) await page.screenshot({ animations: 'disabled', path: `misc/restore-official-${embedded ? 'embedded' : 'full'}.png`, fullPage: true });
|
||||
await core();
|
||||
await page.locator('.agent-model-trigger').click();
|
||||
await page.getByRole('option', { name: 'gpt-two' }).click();
|
||||
await update().click();
|
||||
await waitMode(false);
|
||||
const updated = (await page.evaluate(() => window.fixtureCalls)).findLast(call => call.cmd === 'update_agent_config');
|
||||
assert.equal(updated.args.model, 'gpt-two');
|
||||
assert.equal(updated.args.oauthConfiguration, false);
|
||||
if (!embedded) {
|
||||
await restoreOfficial();
|
||||
await waitMode(true);
|
||||
await page.getByRole('radio', { name: 'OAuth 登录方式', exact: true }).click();
|
||||
await update().click();
|
||||
await waitMode(false);
|
||||
assert.equal((await page.evaluate(() => window.fixtureCalls)).findLast(call => call.cmd === 'update_agent_config').args.oauthConfiguration, true);
|
||||
}
|
||||
}
|
||||
await open('fresh');
|
||||
assert.equal(await update().innerText(), '应用配置修改');
|
||||
assert.equal(await page.locator('.agent-close-configuration').count(), 0);
|
||||
await restoreOfficial();
|
||||
await waitMode(true);
|
||||
await update().click();
|
||||
await waitMode(false);
|
||||
await open('no-core');
|
||||
await restoreOfficial();
|
||||
await waitMode(true);
|
||||
assert.equal(await update().isDisabled(), true, 'An offline core prevents reconnect, but not Restore');
|
||||
await open('fail-native-oauth');
|
||||
await restoreOfficial();
|
||||
await page.getByRole('alert').filter({ hasText: /模拟切换失败/ }).waitFor();
|
||||
await waitMode(false);
|
||||
await restoreOfficial();
|
||||
await waitMode(true);
|
||||
await open('fail-apply');
|
||||
await restoreOfficial();
|
||||
await waitMode(true);
|
||||
await update().click();
|
||||
await page.getByRole('alert').filter({ hasText: /模拟配置写入失败/ }).waitFor();
|
||||
await waitMode(true);
|
||||
await update().click();
|
||||
await waitMode(false);
|
||||
|
||||
const close = () => page.getByRole('button', { name: '关闭配置修改', exact: true });
|
||||
const apply = () => page.getByRole('button', { name: '应用配置修改', exact: true });
|
||||
for (const embedded of [false, true]) {
|
||||
await open(embedded ? 'embedded' : '');
|
||||
await close().waitFor();
|
||||
assert.equal(await update().innerText(), '更新配置');
|
||||
await update().click();
|
||||
await page.waitForFunction(() => document.documentElement.dataset.fixtureApplied === '1');
|
||||
assert.equal((await page.evaluate(() => window.fixtureCalls)).filter(call => call.cmd === 'close_codex_config_modification').length, 0, 'Update never closes an unchanged configuration');
|
||||
await page.locator('.agent-model-trigger').click();
|
||||
await page.getByRole('option', { name: 'gpt-two' }).click();
|
||||
assert.equal(await close().isEnabled(), true, 'A model draft does not hide or disable Close');
|
||||
await close().click();
|
||||
await apply().waitFor();
|
||||
assert.equal(await close().count(), 0, 'Close is hidden after disconnect');
|
||||
assert.equal((await page.evaluate(() => window.fixtureCalls)).filter(call => call.cmd === 'close_codex_config_modification').length, 1);
|
||||
await page.evaluate(() => window.fixtureRemount());
|
||||
await apply().waitFor();
|
||||
await apply().click();
|
||||
await page.waitForFunction(() => !document.querySelector('.agent-close-configuration').disabled);
|
||||
if (!embedded) {
|
||||
await page.getByRole('radio', { name: 'OAuth 登录方式', exact: true }).click();
|
||||
await page.getByRole('button', { name: '更新配置', exact: true }).click();
|
||||
await page.waitForFunction(() => !document.querySelector('.agent-close-configuration').disabled);
|
||||
assert.equal((await page.evaluate(() => window.fixtureCalls)).findLast(call => call.cmd === 'update_agent_config').args.oauthConfiguration, true);
|
||||
await page.getByRole('radio', { name: 'API 密钥', exact: true }).click();
|
||||
assert.equal(await close().isEnabled(), true, 'An authentication draft keeps Close available');
|
||||
await page.getByRole('button', { name: '更新配置', exact: true }).click();
|
||||
await page.waitForFunction(() => !document.querySelector('.agent-close-configuration').disabled);
|
||||
assert.equal((await page.evaluate(() => window.fixtureCalls)).findLast(call => call.cmd === 'update_agent_config').args.oauthConfiguration, false);
|
||||
}
|
||||
}
|
||||
for (const width of [1280, 360]) for (const embedded of [false, true]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await open(`fresh&fail-apply${embedded ? '&embedded' : ''}`);
|
||||
assert.equal(await close().count(), 0, 'An unapplied configuration has no Close button');
|
||||
await assertButtonOrder(false);
|
||||
await apply().click();
|
||||
await page.getByRole('alert').filter({ hasText: /模拟配置写入失败/ }).waitFor();
|
||||
assert.equal(await close().count(), 0, 'A failed application does not reveal Close');
|
||||
await apply().click();
|
||||
await close().waitFor();
|
||||
await assertButtonOrder();
|
||||
await page.evaluate(() => window.fixtureRemount());
|
||||
await close().waitFor();
|
||||
await close().click();
|
||||
await apply().waitFor();
|
||||
assert.equal(await close().count(), 0, 'Closing hides the action again');
|
||||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth > innerWidth), false);
|
||||
}
|
||||
await open('no-core');
|
||||
await close().waitFor();
|
||||
assert.equal(await close().isDisabled(), false, 'Closing is available while CPA is offline');
|
||||
await close().click();
|
||||
await apply().waitFor();
|
||||
await open('fail-close');
|
||||
await close().click();
|
||||
await page.getByRole('alert').filter({ hasText: /模拟关闭失败/ }).waitFor();
|
||||
await close().waitFor();
|
||||
await close().click();
|
||||
await apply().waitFor();
|
||||
for (const width of [1280, 360]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await open('no-oauth-login');
|
||||
const oauth = () => page.getByRole('radio', { name: 'OAuth 登录方式', exact: true });
|
||||
await oauth().click();
|
||||
const dialog = page.getByRole('alertdialog');
|
||||
await dialog.waitFor();
|
||||
assert.match(await dialog.innerText(), /配置管理.*清除接入/);
|
||||
assert.equal((await dialog.innerText()).includes('清空配置'), false);
|
||||
assert.equal(await page.getByRole('radio', { name: 'API 密钥', exact: true }).getAttribute('aria-checked'), 'true');
|
||||
assert.equal((await page.evaluate(() => window.fixtureCalls)).filter(call => ['restore_codex_official_config', 'clear_codex_config', 'update_agent_config'].includes(call.cmd)).length, 0);
|
||||
await dialog.getByRole('button', { name: '前往配置管理', exact: true }).click();
|
||||
await dialog.waitFor({ state: 'detached' });
|
||||
await restore().waitFor();
|
||||
await page.waitForFunction(() => document.activeElement?.id === 'agent-clear-integration');
|
||||
assert.equal((await page.evaluate(() => window.fixtureCalls)).filter(call => call.cmd === 'restore_codex_official_config').length, 0, 'Guidance navigates without clearing configuration');
|
||||
await restore().click();
|
||||
await page.getByRole('status').filter({ hasText: /已清除 CPA 接入/ }).waitFor();
|
||||
assert.equal((await page.evaluate(() => window.fixtureCalls)).filter(call => call.cmd === 'clear_codex_config').length, 0, 'Clear integration never calls Clear configuration');
|
||||
await core();
|
||||
await oauth().click();
|
||||
await dialog.waitFor();
|
||||
await dialog.getByRole('button', { name: '我知道了', exact: true }).click();
|
||||
assert.equal(await page.getByRole('tab', { name: '基础配置', exact: true }).getAttribute('aria-selected'), 'true');
|
||||
await page.evaluate(() => { window.fixtureOauthLoggedIn = true; });
|
||||
await oauth().click();
|
||||
await page.waitForFunction(() => document.querySelector('[data-value="oauth"]')?.getAttribute('aria-checked') === 'true');
|
||||
assert.equal(await dialog.count(), 0, 'An existing login can select OAuth directly');
|
||||
await update().click();
|
||||
await close().waitFor();
|
||||
assert.equal((await page.evaluate(() => window.fixtureCalls)).findLast(call => call.cmd === 'update_agent_config').args.oauthConfiguration, true);
|
||||
}
|
||||
for (const client of ['opencode', 'zcode']) {
|
||||
await page.goto(`${base}/tests/fixtures/agent-backups.html?reset-selections&client=${client}`, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByRole('button', { name: '更新配置', exact: true }).waitFor();
|
||||
assert.equal(await close().count(), 0, 'Other clients keep their existing controls');
|
||||
assert.equal(await restore().count(), 0);
|
||||
await manage();
|
||||
assert.equal(await restore().count(), 0, 'Other clients have no official Codex login action in management');
|
||||
}
|
||||
|
||||
assert.deepEqual(errors, []);
|
||||
console.log('PASS: Clear integration in management, missing-login guidance and navigation, login then OAuth reconnect, independent Close/Update, failures, and both layouts at 1280/360px.');
|
||||
} finally { await browser.close(); }
|
||||
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||
@@ -31,7 +31,7 @@ const assert = require('node:assert/strict');
|
||||
};
|
||||
const apply = async () => {
|
||||
await core();
|
||||
await page.getByRole('button', { name: /^(更新配置|一键接入)$/ }).click();
|
||||
await page.getByRole('button', { name: /^(更新配置|一键接入|应用配置修改)$/ }).click();
|
||||
await page.getByText(/^(配置已更新。|配置已是最新,无需写入。)$/).waitFor();
|
||||
await expectPending(false);
|
||||
};
|
||||
|
||||
Vendored
+21
-3
@@ -11,6 +11,7 @@ document.documentElement.dataset.theme = params.get('theme') || 'light';
|
||||
localStorage.setItem('cpa-gui.agent-selected-client.v1', params.get('client') || 'codex');
|
||||
const ids = ['claude-code','claude-desktop','codex','opencode','openclaw','hermes','deepseek-harness','zcode','kimi-code','grok-build','pi'];
|
||||
let count=0; let backupCount=0; const backups:any[]=[]; let currentModel=params.has('fresh')?null:'gpt-one';
|
||||
let nativeOauth=params.has('native-oauth'); let nativeSwitchCount=0; let codexClosed=false; let closeCount=0;
|
||||
let currentOauth=false; const currentMappings:Record<string,any>={};
|
||||
let harnessProvider:Record<string,unknown>={};
|
||||
const harnessConfigurations:Record<string,Record<string,unknown>>={};
|
||||
@@ -23,6 +24,7 @@ const harnessModels=[
|
||||
const harnessSnapshot=()=>({revision:String(harnessRevision),provider:harnessProvider,baseUrl:'http://127.0.0.1:8317/v1',defaultModel:currentModel,configured:!!currentModel,models:harnessModels.map(m=>({...m,configuration:harnessConfigurations[m.id]??{}}))});
|
||||
let harnessStatus={running:params.has('running'),pid:params.has('running')?100:null as number|null,mode:params.has('running')?params.get('harness-mode')||'web':null as string|null};
|
||||
let appliedCount=0;
|
||||
(window as any).fixtureOauthLoggedIn=!params.has('no-oauth-login');
|
||||
const calls:any[]=[];(window as any).fixtureCalls=calls;
|
||||
let embedded=params.has('embedded');
|
||||
(window as any).fixtureSessionIds=Array.from({length:61},(_,index)=>`session-${index+1}`);
|
||||
@@ -30,8 +32,21 @@ mockIPC(async (cmd,args:any) => {
|
||||
calls.push({cmd,args});
|
||||
if(cmd==='plugin:event|listen') return 1;
|
||||
if(cmd==='plugin:event|unlisten'||cmd==='set_app_locale') return null;
|
||||
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&¤tOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null}));
|
||||
if(cmd==='get_agent_config_statuses'||cmd==='refresh_agent_config_statuses') return ids.map(id=>({id,name:id,supportedPlatform:true,installed:!params.has('not-installed'),pluginInstalled:!params.has('no-plugin'),launchTargets:params.has('not-installed')?[]:['claude-desktop','zcode'].includes(id)?[{id:'app',label:id,detail:'test desktop'}]:['codex','opencode'].includes(id)&&!params.has('cli-only')?[...(params.has('app-only')?[]:[{id:'cli',label:'CLI',detail:'test CLI'}]),{id:'app',label:'APP',detail:'test desktop'}]:[{id:'cli',label:'CLI',detail:'test CLI'}],version:'1.0',cliVersion:'1.0',appVersion:null,pluginVersion:'1.0',configValid:params.get('state')!=='invalid',connectionState:params.get('state') || (currentModel?'configured':'not-configured'),configured:!!currentModel,configurationSynchronized:!!currentModel,currentModel,oauthConfiguration:id==='codex'&¤tOauth,codexNativeOauth:id==='codex'&&nativeOauth,modificationEnabled:!!currentModel,modificationState:currentModel?'applied':'unconfigured',backupAvailable:false,appliedModel:currentModel,claudeCodeModelMappings:id==='claude-code'?currentMappings[id]??null:null,claudeDesktopModelMappings:id==='claude-desktop'?currentMappings[id]??null:null,warnings:[],error:null})).map(status=>status.id==='codex'&&(nativeOauth||codexClosed)?{...status,configured:false,connectionState:'not-configured',configurationSynchronized:false,currentModel:null,appliedModel:null,modificationEnabled:false,modificationState:'unconfigured',oauthConfiguration:false}:status);
|
||||
if(cmd==='close_codex_config_modification') {
|
||||
closeCount++;
|
||||
if(params.has('fail-close')&&closeCount===1)throw new Error('模拟关闭失败,已回滚');
|
||||
codexClosed=true;nativeOauth=false;
|
||||
return {outcome:'updated',enabled:false,changedFiles:[],conflictFiles:[]};
|
||||
}
|
||||
if(cmd==='restore_codex_official_config') {
|
||||
nativeSwitchCount++;
|
||||
if(params.has('fail-native-oauth')&&nativeSwitchCount===1)throw new Error('模拟切换失败,已回滚');
|
||||
nativeOauth=true; return {outcome:'updated'};
|
||||
}
|
||||
if(cmd==='get_agent_models') {
|
||||
if(params.has('no-core'))throw new Error('CPA core is offline');
|
||||
if(params.has('defer-models'))await new Promise<void>(resolve=>{(window as any).fixtureFinishModels=resolve;});
|
||||
if(args.client==='deepseek-harness')return harnessModels.map(m=>({name:m.id,inputModalities:m.defaults.input}));
|
||||
return [{name:'gpt-one'},{name:'gpt-two'}];
|
||||
}
|
||||
@@ -58,7 +73,7 @@ mockIPC(async (cmd,args:any) => {
|
||||
if(cmd==='uninstall_pi_provider')return null;
|
||||
if(['update_agent_config','repair_pi_provider','install_pi_provider','update_pi_provider'].includes(cmd)) {
|
||||
count++;if(params.has('fail-apply')&&count===1)throw new Error('模拟配置写入失败');
|
||||
currentModel=args.model;currentOauth=!!args.oauthConfiguration;
|
||||
currentModel=args.model;currentOauth=!!args.oauthConfiguration;nativeOauth=false;codexClosed=false;
|
||||
currentMappings[args.client]=args.claudeCodeModelMappings??args.claudeDesktopModelMappings;
|
||||
return {outcome:count>1?'unchanged':'updated',model:currentModel,enabled:true,changedFiles:[],conflictFiles:[]};
|
||||
}
|
||||
@@ -85,7 +100,10 @@ mockIPC(async (cmd,args:any) => {
|
||||
harnessRevision++;return harnessSnapshot();
|
||||
}
|
||||
if(cmd==='get_codex_model_catalog_editor') return {models:[],hiddenModels:[],customizations:{}};
|
||||
if(cmd==='check_codex_oauth_login')return null;
|
||||
if(cmd==='check_codex_oauth_login') {
|
||||
if(!(window as any).fixtureOauthLoggedIn)throw new Error('CODEX_OAUTH_LOGIN_REQUIRED');
|
||||
return null;
|
||||
}
|
||||
throw new Error('Unhandled fixture command: '+cmd);
|
||||
});
|
||||
let root=createRoot(document.getElementById('root')!);
|
||||
|
||||
@@ -15,6 +15,8 @@ describe('简易模式、首页、配置与版本管理导航', () => {
|
||||
expect(canOpenAppPage('versions', false)).toBe(true);
|
||||
expect(canOpenAppPage('config', false)).toBe(true);
|
||||
expect(canOpenAppPage('usage-records', false)).toBe(true);
|
||||
expect(canOpenAppPage('agents', false)).toBe(true);
|
||||
expect(isAlwaysAvailablePage('agents')).toBe(true);
|
||||
expect(canOpenAppPage('quota', false)).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user