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:
lzt404 committed 2026-09-15 17:15:21 +08:00
1 parent 76da9b9944
commit 433182f9b0
21 files changed
+1530 -48

No files matched your search

+2
View File
@@ -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::*;
+11 -3
View File
@@ -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")?;
+15 -4
View File
@@ -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,
+528
View File
@@ -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(
&current_document,
&parse_state(native_before[0].1.as_deref())?,
);
state.original_settings = Some(routing_settings(&current_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 &current {
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)
}
+555
View File
@@ -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());
}
+4
View File
@@ -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);
}
+1
View File
@@ -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 {
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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.",
+14 -6
View File
@@ -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
View File
@@ -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);
+12 -1
View File
@@ -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
View File
@@ -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>
+15
View File
@@ -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;
}
+3 -3
View File
@@ -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' });
+2 -2
View File
@@ -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 });
+227
View File
@@ -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; });
+1 -1
View File
@@ -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);
};
+21 -3
View File
@@ -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'&&currentOauth,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'&&currentOauth,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')!);
+2
View File
@@ -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);
});