diff --git a/apps/codex-plus-manager/src-tauri/src/commands.rs b/apps/codex-plus-manager/src-tauri/src/commands.rs index 1f17a6456..e13d069ad 100644 --- a/apps/codex-plus-manager/src-tauri/src/commands.rs +++ b/apps/codex-plus-manager/src-tauri/src/commands.rs @@ -1435,31 +1435,51 @@ pub fn weixin_connect_stop() -> CommandResult CommandResult { - let settings = match SettingsStore::default().load() { - Ok(settings) => settings, - Err(error) => { + // Windows 标准路径:桌面版在用户目录维护、可直接运行的 CLI。 + // Store 包目录(WindowsApps)内的资源受系统保护,第三方进程无法执行(#2028), + // 因此这里不再返回包内路径,避免把必然失败的路径写进设置。 + #[cfg(windows)] + { + return match codex_plus_core::app_paths::find_desktop_managed_codex_cli() { + Some(path) => ok( + "已填入桌面版内置 Codex CLI。", + json!({ "path": path.to_string_lossy() }), + ), + None => failed( + "未找到可运行的桌面版内置 Codex CLI。请先通过 Codex++ 启动一次 Codex 桌面版后重试,\ + 或将「Codex CLI 路径」留空自动查找。", + json!({ "path": null }), + ), + }; + } + #[cfg(not(windows))] + { + let settings = match SettingsStore::default().load() { + Ok(settings) => settings, + Err(error) => { + return failed( + &format!("读取 Codex 应用设置失败:{error}"), + json!({ "path": null }), + ); + } + }; + let Some(app_dir) = codex_plus_core::app_paths::resolve_codex_app_dir_with_saved( + None, + Some(settings.codex_app_path.as_str()), + ) else { + return failed("未找到 Codex Desktop 应用。", json!({ "path": null })); + }; + let Some(path) = codex_plus_core::app_paths::find_bundled_codex_cli(&app_dir) else { return failed( - &format!("读取 Codex 应用设置失败:{error}"), + "已找到 Codex Desktop,但包内没有可用的 Codex CLI。", json!({ "path": null }), ); - } - }; - let Some(app_dir) = codex_plus_core::app_paths::resolve_codex_app_dir_with_saved( - None, - Some(settings.codex_app_path.as_str()), - ) else { - return failed("未找到 Codex Desktop 应用。", json!({ "path": null })); - }; - let Some(path) = codex_plus_core::app_paths::find_bundled_codex_cli(&app_dir) else { - return failed( - "已找到 Codex Desktop,但包内没有可用的 Codex CLI。", - json!({ "path": null }), - ); - }; - ok( - "已填入桌面版内置 Codex CLI。", - json!({ "path": path.to_string_lossy() }), - ) + }; + ok( + "已填入桌面版内置 Codex CLI。", + json!({ "path": path.to_string_lossy() }), + ) + } } fn spawn_weixin_connect( diff --git a/crates/codex-plus-core/src/app_paths.rs b/crates/codex-plus-core/src/app_paths.rs index 4d9726e1b..e6842de4a 100644 --- a/crates/codex-plus-core/src/app_paths.rs +++ b/crates/codex-plus-core/src/app_paths.rs @@ -568,6 +568,52 @@ pub fn find_bundled_codex_cli(app_dir: &Path) -> Option { candidates.into_iter().find(|candidate| candidate.is_file()) } +/// 是否指向 Store 版安装目录(WindowsApps)里的可执行文件。 +/// +/// MSIX 包目录受系统保护,第三方进程不能直接执行其中的 exe, +/// 改文件夹权限也不会生效(#2028:微信连接填「桌面版内置 CLI」必然 os error 5)。 +/// macOS 的 .app/Contents/Resources/codex 是普通可执行文件,不受此限制。 +pub fn is_windows_store_cli_path(executable: &str) -> bool { + executable + .replace('/', "\\") + .to_ascii_lowercase() + .contains("\\windowsapps\\") +} + +/// 桌面版在用户目录维护的 Codex CLI——Windows 上的标准路径。 +/// +/// Store 版桌面应用会把可独立执行的 CLI 放在 +/// `%LOCALAPPDATA%\OpenAI\Codex\bin\<哈希>\codex.exe` 并随桌面版一起更新; +/// 该目录在系统保护目录之外,第三方进程可以直接运行。目录名是内容哈希, +/// 每次更新都会变,所以不能缓存、只按「含 codex.exe 的最新子目录」解析; +/// 兼容旧的平铺布局 `bin\codex.exe`(#2028/#1879 的根治路径)。 +pub fn find_desktop_managed_codex_cli() -> Option { + find_desktop_managed_codex_cli_from_var( + std::env::var_os("LOCALAPPDATA").as_deref().map(Path::new), + ) +} + +fn find_desktop_managed_codex_cli_from_var(local_appdata: Option<&Path>) -> Option { + let bin = local_appdata?.join("OpenAI").join("Codex").join("bin"); + let mut candidates = vec![bin.join("codex.exe")]; + if let Ok(entries) = std::fs::read_dir(&bin) { + for entry in entries.filter_map(Result::ok) { + candidates.push(entry.path().join("codex.exe")); + } + } + candidates + .into_iter() + .filter(|exe| exe.is_file()) + .filter_map(|exe| { + let modified = std::fs::metadata(&exe) + .and_then(|metadata| metadata.modified()) + .ok()?; + Some((modified, exe)) + }) + .max_by(|left, right| left.0.cmp(&right.0)) + .map(|(_, exe)| exe) +} + pub fn codex_app_version(app_dir: &Path) -> Option { if app_dir.extension() == Some(OsStr::new("app")) { return macos_app_version(app_dir); @@ -855,3 +901,78 @@ mod tests { ); } } + +#[cfg(test)] +mod cli_path_tests { + use super::{find_desktop_managed_codex_cli_from_var, is_windows_store_cli_path}; + use std::path::Path; + use std::time::{Duration, SystemTime}; + + fn touch(path: &Path, ago_secs: u64) { + let file = std::fs::File::options().append(true).open(path).unwrap(); + file.set_modified(SystemTime::now() - Duration::from_secs(ago_secs)) + .unwrap(); + } + + /// #2028:桌面版把 CLI 维护在 bin\<哈希>\ 下,哈希目录随更新变化, + /// 解析必须挑含 codex.exe 的最新子目录,跳过只放辅助工具的目录。 + #[test] + fn picks_newest_hash_dir_containing_codex_exe() { + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("OpenAI").join("Codex").join("bin"); + let old = bin.join("aaa111"); + let new = bin.join("bbb222"); + let rg_only = bin.join("ccc333"); + std::fs::create_dir_all(&old).unwrap(); + std::fs::create_dir_all(&new).unwrap(); + std::fs::create_dir_all(&rg_only).unwrap(); + std::fs::write(old.join("codex.exe"), "old").unwrap(); + std::fs::write(new.join("codex.exe"), "new").unwrap(); + std::fs::write(rg_only.join("rg.exe"), "rg").unwrap(); + touch(&old.join("codex.exe"), 10_000); + touch(&new.join("codex.exe"), 60); + touch(&rg_only.join("rg.exe"), 1); + + let found = find_desktop_managed_codex_cli_from_var(Some(temp.path())); + assert_eq!(found.as_deref(), Some(new.join("codex.exe").as_path())); + } + + #[test] + fn flat_bin_layout_is_still_accepted() { + let temp = tempfile::tempdir().unwrap(); + let bin = temp.path().join("OpenAI").join("Codex").join("bin"); + std::fs::create_dir_all(&bin).unwrap(); + std::fs::write(bin.join("codex.exe"), "flat").unwrap(); + + let found = find_desktop_managed_codex_cli_from_var(Some(temp.path())); + assert_eq!(found.as_deref(), Some(bin.join("codex.exe").as_path())); + } + + #[test] + fn missing_bin_directory_returns_none() { + let temp = tempfile::tempdir().unwrap(); + assert_eq!( + find_desktop_managed_codex_cli_from_var(Some(temp.path())), + None + ); + assert_eq!(find_desktop_managed_codex_cli_from_var(None), None); + } + + #[test] + fn detects_windows_store_paths_across_separators_and_cases() { + assert!(is_windows_store_cli_path( + r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0_x64__p\app\resources\codex.exe" + )); + assert!(is_windows_store_cli_path( + "c:/program files/windowsapps/someapp/app/codex.exe" + )); + // 桌面版维护的 bin 目录 / macOS 内置路径 / 裸命令名都不能误伤 + assert!(!is_windows_store_cli_path( + r"C:\Users\a\AppData\Local\OpenAI\Codex\bin\abc123\codex.exe" + )); + assert!(!is_windows_store_cli_path( + "/Applications/ChatGPT.app/Contents/Resources/codex" + )); + assert!(!is_windows_store_cli_path("codex")); + } +} diff --git a/crates/codex-plus-core/src/connect/app_server.rs b/crates/codex-plus-core/src/connect/app_server.rs index 5a11790f5..d7156e6ce 100644 --- a/crates/codex-plus-core/src/connect/app_server.rs +++ b/crates/codex-plus-core/src/connect/app_server.rs @@ -51,6 +51,19 @@ fn collect_stderr_tail(sink: &Arc>>) -> String { .unwrap_or_default() } +/// 留空时不让用户猜路径:Windows 直接选用桌面版维护的标准 CLI, +/// 其余平台沿用 PATH 里的 codex(macOS 桌面版内置路径本来就可执行)。 +fn resolve_startup_executable(configured: &str) -> String { + if !configured.is_empty() { + return configured.to_string(); + } + #[cfg(windows)] + if let Some(path) = crate::app_paths::find_desktop_managed_codex_cli() { + return path.to_string_lossy().into_owned(); + } + "codex".to_string() +} + /// 启动前先把「路径本身就不对」的情况挑出来。 /// /// 之前不管什么原因失败,用户只会看到一句「请检查 Codex CLI 路径」——填的是目录、 @@ -65,6 +78,13 @@ fn validate_codex_executable(executable: &str) -> anyhow::Result<()> { if !looks_like_path { return Ok(()); } + if crate::app_paths::is_windows_store_cli_path(executable) { + bail!( + "Codex CLI 路径位于系统保护的安装目录内,Codex++ 无法直接运行它:{executable}\n\ + 请清空「Codex CLI 路径」保存(留空时自动查找),\ + 或点击「使用桌面版内置 CLI」重新选择。" + ); + } let path = Path::new(executable); if !path.exists() { bail!( @@ -91,15 +111,34 @@ fn validate_codex_executable(executable: &str) -> anyhow::Result<()> { Ok(()) } +/// spawn 失败按错误类别给出对应提示,不再一律归成「请检查路径」。 +fn spawn_failure_hint(executable: &str, kind: std::io::ErrorKind) -> &'static str { + match kind { + std::io::ErrorKind::NotFound => { + if cfg!(windows) && !executable.contains('/') && !executable.contains('\\') { + // CreateProcess 解析裸命令名只补 .exe,npm 生成的 codex.cmd 不在其列(#1879) + ":找不到可运行的 Codex CLI;请点击「使用桌面版内置 CLI」自动填入,\ + 或将「Codex CLI 路径」留空自动查找" + } else { + ":找不到该文件;填 Codex CLI 可执行文件的完整路径,或确保 codex 在 PATH 里" + } + } + std::io::ErrorKind::PermissionDenied + if crate::app_paths::is_windows_store_cli_path(executable) => + { + ":此路径位于系统保护的安装目录,无法直接运行;\ + 请清空「Codex CLI 路径」自动查找,或点击「使用桌面版内置 CLI」重新选择" + } + std::io::ErrorKind::PermissionDenied => ":没有执行权限", + _ => "", + } +} + impl CodexAppServer { pub async fn start(config: AppServerConfig) -> anyhow::Result { - let executable = if config.executable.trim().is_empty() { - "codex" - } else { - config.executable.trim() - }; - validate_codex_executable(executable)?; - let mut command = Command::new(executable); + let executable = resolve_startup_executable(config.executable.trim()); + validate_codex_executable(&executable)?; + let mut command = Command::new(&executable); command .arg("app-server") .current_dir(&config.work_dir) @@ -108,13 +147,7 @@ impl CodexAppServer { .stderr(Stdio::piped()) .kill_on_drop(true); let mut child = command.spawn().map_err(|error| { - let hint = match error.kind() { - std::io::ErrorKind::NotFound => { - ":找不到该文件;填 Codex CLI 可执行文件的完整路径,或确保 codex 在 PATH 里" - } - std::io::ErrorKind::PermissionDenied => ":没有执行权限", - _ => "", - }; + let hint = spawn_failure_hint(&executable, error.kind()); anyhow::anyhow!("无法启动 Codex app-server({executable}){hint}({error})") })?; let stdin = child @@ -673,6 +706,56 @@ mod tests { assert!(validate_codex_executable("codex").is_ok()); } + /// #2028:系统保护目录(WindowsApps)路径在启动前就被拦下, + /// 并直接告诉用户怎么改,而不是等 spawn 失败后一句「没有执行权限」。 + #[test] + fn windows_store_cli_path_is_rejected_with_actionable_message() { + let error = validate_codex_executable( + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.915.4065.0_x64__2p2nqsd0c76g0\app\resources\codex.exe", + ) + .unwrap_err(); + let message = error.to_string(); + assert!(message.contains("系统保护"), "{message}"); + assert!(message.contains("留空"), "{message}"); + assert!(message.contains("使用桌面版内置 CLI"), "{message}"); + } + + /// 留空即自动选用:显式填写的路径原样透传;空值在 Windows 上优先 + /// 桌面版维护的标准 CLI,取不到时退回 PATH(#2028 的零配置路径)。 + #[test] + fn empty_executable_resolves_to_standard_cli_or_path() { + assert_eq!( + resolve_startup_executable("C:/x/codex.exe"), + "C:/x/codex.exe" + ); + let fallback = resolve_startup_executable(""); + assert!( + fallback == "codex" || fallback.to_ascii_lowercase().ends_with("codex.exe"), + "{fallback}" + ); + } + + #[test] + fn spawn_hints_distinguish_windows_store_and_missing_cli_cases() { + let store = spawn_failure_hint( + r"C:\Program Files\WindowsApps\OpenAI.Codex_1.0\app\resources\codex.exe", + std::io::ErrorKind::PermissionDenied, + ); + assert!(store.contains("系统保护"), "{store}"); + assert!(store.contains("清空"), "{store}"); + + let plain = + spawn_failure_hint(r"C:\codex\codex.exe", std::io::ErrorKind::PermissionDenied); + assert_eq!(plain, ":没有执行权限"); + + let missing = spawn_failure_hint("codex", std::io::ErrorKind::NotFound); + if cfg!(windows) { + assert!(missing.contains("使用桌面版内置 CLI"), "{missing}"); + } else { + assert!(missing.contains("PATH"), "{missing}"); + } + } + #[cfg(unix)] #[test] fn executable_validation_flags_a_file_without_exec_permission() { diff --git a/crates/codex-plus-core/src/connect/mod.rs b/crates/codex-plus-core/src/connect/mod.rs index 192430f99..28fbb4bd8 100644 --- a/crates/codex-plus-core/src/connect/mod.rs +++ b/crates/codex-plus-core/src/connect/mod.rs @@ -207,10 +207,19 @@ pub async fn run_weixin_connect_with_codex_path( if !message.is_finished_user_message() || message.is_older_than(now_ms(), MAX_INBOUND_MESSAGE_AGE_MS) || state.is_processed(&message_key) - || !is_allowed_peer(&config.allow_from, &message.from_user_id) { continue; } + // 白名单外的消息以前完全静默:不回复也没有任何提示, + // 用户无法区分「消息没到」和「被过滤了」。 + if !is_allowed_peer(&config.allow_from, &message.from_user_id) { + update_status(&status, |current| { + current.state = "running".to_string(); + current.message = + format!("已忽略来自非白名单发送方的消息:{}", message.from_user_id); + }); + continue; + } let Some(text) = message.text() else { state.mark_processed(&message_key); continue; @@ -301,6 +310,9 @@ async fn process_weixin_message( text: &str, stop: &AtomicBool, ) -> anyhow::Result<()> { + if let Some(reason) = local_model_endpoint_unreachable_reason().await { + bail!("{reason}"); + } if app_server .as_ref() .map(|server| !server.is_running()) @@ -405,6 +417,41 @@ fn is_allowed_peer(allow_from: &str, peer: &str) -> bool { .any(|allowed| !allowed.is_empty() && allowed == peer) } +/// 模型流量若走本地代理(config.toml 的 openai_base_url 指向 127.0.0.1, +/// 由 Codex++ 的单模型路由写入),该代理只随从 Codex++ 启动的桌面版存在。 +/// 提前探测,避免 turn 失败后只剩一句模糊的「处理失败」。 +async fn local_model_endpoint_unreachable_reason() -> Option { + let base_url = std::fs::read_to_string( + crate::relay_config::default_codex_home_dir().join("config.toml"), + ) + .ok() + .and_then(|contents| crate::relay_config::root_key_string(&contents, "openai_base_url"))?; + let (host, port) = localhost_endpoint(&base_url)?; + let connect = tokio::net::TcpStream::connect((host.as_str(), port)); + if tokio::time::timeout(std::time::Duration::from_millis(300), connect) + .await + .is_ok_and(|result| result.is_ok()) + { + return None; + } + Some( + "Codex 桌面版当前未运行,微信连接暂时无法调用模型。\ + 请先从 Codex++ 启动 Codex 桌面版后重试。" + .to_string(), + ) +} + +/// 只对「本地回环 + 显式端口」的 base_url 预检; +/// 直连中转站或未带端口的地址不属于桌面版代理,返回 None 表示跳过。 +fn localhost_endpoint(base_url: &str) -> Option<(String, u16)> { + let url = reqwest::Url::parse(base_url.trim()).ok()?; + let host = url.host_str()?; + if host != "127.0.0.1" && host != "localhost" { + return None; + } + Some((host.to_string(), url.port()?)) +} + fn normalize_sandbox(value: &str) -> String { match value.trim() { "workspace-write" => "workspace-write", @@ -472,6 +519,97 @@ mod tests { assert!(!is_allowed_peer("a@im.wechat", "b@im.wechat")); } + /// 白名单外的消息不再无声消失:状态栏要能看到「已忽略」, + /// 否则用户无法区分「消息没到」和「被过滤」(群内真实发生过)。 + /// 用 wiremock 假微信网关驱动真实消息循环验证。 + #[tokio::test] + async fn non_allowlisted_message_updates_status_and_is_dropped() { + let server = wiremock::MockServer::start().await; + let message = serde_json::json!({ + "seq": 1, + "message_id": (now_ms() % 1_000_000) as i64, + "from_user_id": "stranger@im.wechat", + "client_id": "unit-test", + "create_time_ms": now_ms() as i64, + "message_type": 1, + "message_state": 2, + "item_list": [{ "type": 1, "text_item": { "text": "hi" } }], + "context_token": "ctx" + }); + let updates = serde_json::json!({ + "ret": 0, + "errcode": 0, + "errmsg": "", + "msgs": [message], + "get_updates_buf": "YnVm", + "longpolling_timeout_ms": 1000 + }); + wiremock::Mock::given(wiremock::matchers::method("POST")) + .and(wiremock::matchers::path("/ilink/bot/getupdates")) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(updates) + .set_delay(std::time::Duration::from_millis(300)), + ) + .mount(&server) + .await; + + let work_dir = tempfile::tempdir().unwrap(); + let config = WeixinConnectConfig { + base_url: server.uri(), + token: "tok".to_string(), + account_id: "unit-test-ignore".to_string(), + allow_from: "only-me@im.wechat".to_string(), + route_tag: String::new(), + work_dir: work_dir.path().display().to_string(), + model: String::new(), + sandbox: "read-only".to_string(), + codex_path: String::new(), + }; + let status: SharedWeixinConnectStatus = Arc::new(Mutex::new(Default::default())); + let stop = Arc::new(AtomicBool::new(false)); + let task = tokio::spawn(run_weixin_connect_with_codex_path( + config, + Arc::clone(&stop), + Arc::clone(&status), + WeixinCodexPath::new(""), + )); + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + // 循环正常退出时会写「微信连接已停止」,必须在停止前快照处理状态 + let snapshot = status + .lock() + .map(|current| current.message.clone()) + .unwrap_or_default(); + stop.store(true, Ordering::SeqCst); + let _ = tokio::time::timeout(std::time::Duration::from_secs(5), task).await; + + let message = snapshot; + assert!( + message.contains("已忽略来自非白名单发送方的消息"), + "状态栏应提示已忽略,实际:{message}" + ); + // 清理循环写入真实 app state 目录的测试状态文件 + let _ = std::fs::remove_file( + crate::paths::default_app_state_dir().join("weixin-connect-state-unit-test-ignore.json"), + ); + } + + #[test] + fn localhost_endpoint_only_matches_explicit_local_ports() { + assert_eq!( + localhost_endpoint("http://127.0.0.1:57321/v1"), + Some(("127.0.0.1".to_string(), 57321)) + ); + assert_eq!( + localhost_endpoint("http://localhost:8080/v1"), + Some(("localhost".to_string(), 8080)) + ); + // 直连中转站不属于桌面版代理,不预检 + assert_eq!(localhost_endpoint("https://api.example.com/v1"), None); + // 无显式端口的本地地址也不预检,避免误判成「桌面版未运行」 + assert_eq!(localhost_endpoint("http://127.0.0.1/v1"), None); + } + #[test] fn config_normalizes_base_url_and_sandbox() { let config = WeixinConnectConfig { diff --git a/crates/codex-plus-core/src/settings.rs b/crates/codex-plus-core/src/settings.rs index 582f303a5..45ff3dad9 100644 --- a/crates/codex-plus-core/src/settings.rs +++ b/crates/codex-plus-core/src/settings.rs @@ -1739,6 +1739,12 @@ fn normalize_settings_config_sections(mut settings: BackendSettings) -> BackendS } .to_string(); settings.weixin_connect_codex_path = settings.weixin_connect_codex_path.trim().to_string(); + // #2028 存量迁移:系统保护目录(WindowsApps)里的 CLI 无法被第三方进程执行, + // 早期版本点「使用桌面版内置 CLI」写入过这类路径;置空后启动时会自动 + // 选用桌面版维护的标准 CLI,用户无需手动清理。 + if crate::app_paths::is_windows_store_cli_path(&settings.weixin_connect_codex_path) { + settings.weixin_connect_codex_path = String::new(); + } settings.codex_app_stepwise_max_items = clamp_stepwise_max_items(settings.codex_app_stepwise_max_items); settings.codex_app_stepwise_max_input_chars = @@ -2956,6 +2962,25 @@ experimental_bearer_token = "sk-existing""# assert_eq!(store.load().unwrap(), updated); } + /// #2028 存量迁移:系统保护目录(WindowsApps)里的 CLI 无法被第三方进程执行, + /// 旧版点「使用桌面版内置 CLI」写入过这类路径;归一化时置空, + /// 让连接启动时自动选用桌面版维护的标准 CLI。 + #[test] + fn windows_store_codex_path_is_cleared_for_auto_resolution() { + let dir = temp_dir(); + let store = SettingsStore::new(dir.join("settings.json")); + + let updated = store + .update(json!({ + "weixinConnectCodexPath": + r"C:\Program Files\WindowsApps\OpenAI.Codex_26.915.4065.0_x64__2p2nqsd0c76g0\app\resources\codex.exe" + })) + .unwrap(); + + assert_eq!(updated.weixin_connect_codex_path, ""); + assert_eq!(store.load().unwrap().weixin_connect_codex_path, ""); + } + #[test] fn settings_store_update_persists_launch_mode() { let dir = temp_dir();