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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 42 additions & 22 deletions apps/codex-plus-manager/src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1435,31 +1435,51 @@ pub fn weixin_connect_stop() -> CommandResult<codex_plus_core::connect::WeixinCo

#[tauri::command]
pub fn find_desktop_codex_cli() -> CommandResult<Value> {
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(
Expand Down
121 changes: 121 additions & 0 deletions crates/codex-plus-core/src/app_paths.rs
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,52 @@ pub fn find_bundled_codex_cli(app_dir: &Path) -> Option<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<String> {
if app_dir.extension() == Some(OsStr::new("app")) {
return macos_app_version(app_dir);
Expand Down Expand Up @@ -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"));
}
}
111 changes: 97 additions & 14 deletions crates/codex-plus-core/src/connect/app_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ fn collect_stderr_tail(sink: &Arc<Mutex<VecDeque<String>>>) -> 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 路径」——填的是目录、
Expand All @@ -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!(
Expand All @@ -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<Self> {
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)
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down
Loading
Loading