From 1a91d0b1cd209ce0f4313e833aec47232eb68718 Mon Sep 17 00:00:00 2001 From: vgm Date: Sun, 4 Oct 2026 17:18:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A7=A3=E8=80=A6=E5=8E=9F=E7=94=9F=20?= =?UTF-8?q?Codex=20CLI=20=E9=80=89=E6=8B=A9=20/=20decouple=20native=20Code?= =?UTF-8?q?x=20CLI=20selection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 增加 --codex-cli,优先于继承的 CODEX_CLI_PATH;未指定时保留平台默认行为。 显式 CLI 不依赖包内文件或 Desktop 管理的缓存,无效路径直接报错,拒绝 Shim 自引用。 保留受管环境再次调用的原生转发目标,并补齐 inspect 来源诊断、回归测试和中英文文档。 Add --codex-cli ahead of inherited CODEX_CLI_PATH while preserving platform defaults. Explicit CLIs do not require packaged files or the Desktop-managed cache; invalid paths fail and Shim recursion is rejected. Preserve native forwarding targets on managed reentry and add inspect provenance, regression tests, and bilingual documentation. --- crates/launcher/src/console.rs | 35 +++ crates/launcher/src/main.rs | 116 ++++++- crates/platform/src/codex_cli.rs | 297 ++++++++++++++++++ crates/platform/src/desktop_launch.rs | 33 ++ crates/platform/src/installation.rs | 255 +++++++++++++-- crates/platform/src/lib.rs | 11 +- crates/platform/src/linux_installation.rs | 84 ++++- .../native-codex-cli-selection.md | 50 +++ docs/index.md | 1 + docs/platforms/linux/linux.md | 4 + docs/platforms/linux/linux.zh-CN.md | 4 + packages/console-server/src/installation.ts | 4 + .../console-server/test/installation.test.ts | 28 ++ 13 files changed, 866 insertions(+), 56 deletions(-) create mode 100644 crates/platform/src/codex_cli.rs create mode 100644 docs/architecture/native-codex-cli-selection.md diff --git a/crates/launcher/src/console.rs b/crates/launcher/src/console.rs index bf0c76728..deba79fa2 100644 --- a/crates/launcher/src/console.rs +++ b/crates/launcher/src/console.rs @@ -315,6 +315,9 @@ fn desktop_json(installation: &DesktopInstallation, process_ids: &[u32]) -> Valu "version": installation.version, "build": installation.build, "installRoot": installation.install_root.display().to_string(), + "packagedCodexCli": installation.packaged_codex_cli.display().to_string(), + "executableCodexCli": installation.executable_codex_cli.display().to_string(), + "codexCliSource": installation.codex_cli_source.as_str(), "processIds": process_ids, }) } @@ -362,6 +365,38 @@ pub fn inspect_json( mod tests { use super::*; + #[test] + fn inspect_desktop_distinguishes_packaged_and_selected_cli_with_source() { + let root = std::env::temp_dir().join("inspect-cli"); + let installation = DesktopInstallation { + identity: DesktopIdentity::LinuxPackage { + package_name: "chatgpt".into(), + brand: "chatgpt".into(), + flavor: "prod".into(), + }, + version: "1.2.3".into(), + build: "1.2.3".into(), + asar_integrity: "sha256:fixture".into(), + install_root: root.clone(), + desktop_launcher: root.join("launcher"), + desktop_executable: root.join("ChatGPT"), + packaged_codex_cli: root.join("resources/codex"), + executable_codex_cli: root.join("independent/codex"), + codex_cli_source: codexhost_platform::CodexCliSource::Environment, + }; + let document = desktop_json(&installation, &[123]); + assert_eq!( + document["packagedCodexCli"], + installation.packaged_codex_cli.display().to_string() + ); + assert_eq!( + document["executableCodexCli"], + installation.executable_codex_cli.display().to_string() + ); + assert_eq!(document["codexCliSource"], "environment"); + assert_eq!(document["processIds"], json!([123])); + } + #[test] fn console_node_suppresses_only_the_experimental_proxy_warning() { let command = node_command(&ConsoleCommand { diff --git a/crates/launcher/src/main.rs b/crates/launcher/src/main.rs index 3c7b1fe37..73212bb0e 100644 --- a/crates/launcher/src/main.rs +++ b/crates/launcher/src/main.rs @@ -37,10 +37,10 @@ use codexhost_platform::{ APPX_RESUME_ARGUMENT, DesktopProcess, launch_desktop, resume_packaged_application, }; use codexhost_platform::{ - DesktopIdentity, DesktopInstallation, DesktopLaunchMode, SupervisedChild, + CodexCliOverride, DesktopIdentity, DesktopInstallation, DesktopLaunchMode, SupervisedChild, canonical_existing_file, configure_background_command, - desktop_root_process_ids_for_installation, discover_codex_desktop, node_entrypoint_path, - spawn_supervised, + desktop_root_process_ids_for_installation, discover_codex_desktop_with_cli, + node_entrypoint_path, resolve_codex_cli_override, spawn_supervised, }; #[cfg(any(target_os = "macos", target_os = "linux"))] use codexhost_platform::{DesktopSession, launch_desktop_session}; @@ -129,7 +129,7 @@ impl Error for UnmanagedDesktopConflict {} fn usage() { eprintln!( - "usage:\n codexhost\n codexhost inspect [--json] [--custom-install ]\n codexhost console\n codexhost update\n codexhost launch [--shim ] [--node ] [--host-runtime ] [--desktop-controller ] [--renderer ] [--pi ] [--custom-install ]\n codexhost broker install|status|stop|uninstall\n codexhost delegate --help\n codexhost harness inspect ...\n codexhost delegate start ...\n codexhost thread send|cancel|read|wait|list ..." + "usage:\n codexhost\n codexhost inspect [--json] [--codex-cli ] [--custom-install ]\n codexhost console\n codexhost update\n codexhost launch [--shim ] [--node ] [--host-runtime ] [--desktop-controller ] [--renderer ] [--pi ] [--codex-cli ] [--custom-install ]\n codexhost broker install|status|stop|uninstall\n codexhost delegate --help\n codexhost harness inspect ...\n codexhost delegate start ...\n codexhost thread send|cancel|read|wait|list ..." ); } @@ -286,6 +286,10 @@ fn print_installation(installation: &DesktopInstallation, process_ids: &[u32]) { "executable_codex_cli={}", installation.executable_codex_cli.display() ); + println!( + "codex_cli_source={}", + installation.codex_cli_source.as_str() + ); let process_list = process_ids .iter() .map(u32::to_string) @@ -302,23 +306,40 @@ fn print_installation(installation: &DesktopInstallation, process_ids: &[u32]) { /// PackageManager. fn discover_desktop( custom_install_root: Option<&Path>, + selected_cli: Option<&CodexCliOverride>, ) -> Result> { match custom_install_root { #[cfg(target_os = "windows")] - Some(root) => Ok(codexhost_platform::discover_codex_desktop_from_root(root)?), + Some(root) => { + Ok(codexhost_platform::discover_codex_desktop_from_root_with_cli(root, selected_cli)?) + } #[cfg(not(target_os = "windows"))] Some(_) => Err("--custom-install is supported on Windows only".into()), - None => Ok(discover_codex_desktop()?), + None => Ok(discover_codex_desktop_with_cli(selected_cli)?), } } -fn inspect(custom_install_root: Option<&Path>, json: bool) -> Result<(), Box> { +fn inspect( + custom_install_root: Option<&Path>, + codex_cli: Option<&Path>, + json: bool, +) -> Result<(), Box> { + let discovered = (|| { + let installed = InstalledResources::from_current_executable()?; + let selected_cli = resolve_codex_cli_override( + codex_cli, + env::var_os(codexhost_platform::CODEX_CLI_PATH_ENV).as_deref(), + env::var_os(codexhost_platform::STOCK_CODEX_PATH_ENV).as_deref(), + installed.shim.is_file().then_some(installed.shim.as_path()), + )?; + discover_desktop(custom_install_root, selected_cli.as_ref()) + })(); if json { - let document = console::inspect_json(discover_desktop(custom_install_root))?; + let document = console::inspect_json(discovered)?; println!("{}", serde_json::to_string(&document)?); return Ok(()); } - let installation = discover_desktop(custom_install_root)?; + let installation = discovered?; let process_ids = codexhost_platform::desktop_process_ids_for_installation(&installation)?; print_installation(&installation, &process_ids); Ok(()) @@ -333,6 +354,7 @@ struct LaunchOptions { renderer_extension: Option, pi: Option, custom_install_root: Option, + codex_cli: Option, } #[derive(Debug)] @@ -344,6 +366,7 @@ struct ResolvedLaunchOptions { renderer_extension: PathBuf, pi: Option, custom_install_root: Option, + codex_cli: Option, } fn required_path(arguments: &[String], index: &mut usize, option: &str) -> Result { @@ -362,6 +385,7 @@ fn parse_launch_options(arguments: &[String]) -> Result { let mut renderer_extension = None; let mut pi = None; let mut custom_install_root = None; + let mut codex_cli = None; let mut index = 0; while index < arguments.len() { match arguments[index].as_str() { @@ -381,6 +405,12 @@ fn parse_launch_options(arguments: &[String]) -> Result { renderer_extension = Some(required_path(arguments, &mut index, "--renderer")?) } "--pi" => pi = Some(required_path(arguments, &mut index, "--pi")?), + "--codex-cli" => { + if codex_cli.is_some() { + return Err("--codex-cli may only be provided once".into()); + } + codex_cli = Some(required_path(arguments, &mut index, "--codex-cli")?); + } "--custom-install" => { custom_install_root = Some(required_path(arguments, &mut index, "--custom-install")?) @@ -397,6 +427,7 @@ fn parse_launch_options(arguments: &[String]) -> Result { renderer_extension, pi, custom_install_root, + codex_cli, }) } @@ -404,6 +435,7 @@ fn parse_launch_options(arguments: &[String]) -> Result { #[derive(Debug, Default, PartialEq, Eq)] struct InspectOptions { custom_install_root: Option, + codex_cli: Option, json: bool, } @@ -416,6 +448,12 @@ fn parse_inspect_options(arguments: &[String]) -> Result options.custom_install_root = Some(required_path(arguments, &mut index, "--custom-install")?) } + "--codex-cli" => { + if options.codex_cli.is_some() { + return Err("--codex-cli may only be provided once".into()); + } + options.codex_cli = Some(required_path(arguments, &mut index, "--codex-cli")?); + } "--json" => options.json = true, unknown => return Err(format!("unknown inspect option: {unknown}")), } @@ -457,8 +495,16 @@ fn resolve_resource_path( impl LaunchOptions { fn resolve(self) -> Result> { let installed = InstalledResources::from_current_executable()?; + let shim = resolve_resource_path(self.shim, &installed.shim, "--shim", "bundled Shim")?; + let codex_cli = resolve_codex_cli_override( + self.codex_cli.as_deref(), + env::var_os(codexhost_platform::CODEX_CLI_PATH_ENV).as_deref(), + env::var_os(codexhost_platform::STOCK_CODEX_PATH_ENV).as_deref(), + Some(&shim), + )?; Ok(ResolvedLaunchOptions { - shim: resolve_resource_path(self.shim, &installed.shim, "--shim", "bundled Shim")?, + shim, + codex_cli, node: resolve_resource_path( self.node, &installed.node, @@ -1029,7 +1075,10 @@ fn launch( start_launch_console(&options); let options = options.resolve()?; startup_trace("resources resolved"); - let installation = discover_desktop(options.custom_install_root.as_deref())?; + let installation = discover_desktop( + options.custom_install_root.as_deref(), + options.codex_cli.as_ref(), + )?; startup_record::desktop( &installation.version, &installation.build, @@ -1166,7 +1215,10 @@ fn launch( start_launch_console(&options); let options = options.resolve()?; startup_trace("resources resolved"); - let installation = discover_desktop(options.custom_install_root.as_deref())?; + let installation = discover_desktop( + options.custom_install_root.as_deref(), + options.codex_cli.as_ref(), + )?; startup_record::desktop( &installation.version, &installation.build, @@ -1293,6 +1345,7 @@ fn default_launch_options() -> LaunchOptions { renderer_extension: None, pi: None, custom_install_root: None, + codex_cli: None, } } @@ -1310,7 +1363,11 @@ fn run(arguments: &[String]) -> Result<(), Box> { .custom_install_root .map(|path| absolute_directory(&path, "--custom-install")) .transpose()?; - inspect(custom_install_root.as_deref(), options.json) + inspect( + custom_install_root.as_deref(), + options.codex_cli.as_deref(), + options.json, + ) } Some("console") if arguments.len() == 1 => run_console_command("open"), Some("update") if arguments.len() == 1 => run_console_command("update"), @@ -1611,6 +1668,38 @@ mod tests { assert!(parse_inspect_options(&["--unknown".into()]).is_err()); } + #[test] + fn native_cli_option_is_accepted_by_launch_and_inspect() { + let path = std::env::temp_dir().join("native-codex"); + let arguments = ["--codex-cli".into(), path.display().to_string()]; + assert_eq!( + parse_launch_options(&arguments) + .unwrap() + .codex_cli + .as_deref(), + Some(path.as_path()) + ); + assert_eq!( + parse_inspect_options(&arguments) + .unwrap() + .codex_cli + .as_deref(), + Some(path.as_path()) + ); + assert!(parse_launch_options(&[]).unwrap().codex_cli.is_none()); + assert!(parse_inspect_options(&[]).unwrap().codex_cli.is_none()); + assert!(parse_launch_options(&["--codex-cli".into()]).is_err()); + assert!(parse_inspect_options(&["--codex-cli".into()]).is_err()); + let duplicate = [ + "--codex-cli".into(), + path.display().to_string(), + "--codex-cli".into(), + path.display().to_string(), + ]; + assert!(parse_launch_options(&duplicate).is_err()); + assert!(parse_inspect_options(&duplicate).is_err()); + } + #[test] fn custom_install_root_must_be_an_existing_absolute_directory() { assert!(absolute_directory(Path::new("relative/path"), "--custom-install").is_err()); @@ -1633,6 +1722,7 @@ mod tests { renderer_extension: PathBuf::from("/opt/renderer-extension.js"), pi: None, custom_install_root: None, + codex_cli: None, } } diff --git a/crates/platform/src/codex_cli.rs b/crates/platform/src/codex_cli.rs new file mode 100644 index 000000000..1ed56aaf7 --- /dev/null +++ b/crates/platform/src/codex_cli.rs @@ -0,0 +1,297 @@ +//! Native Codex CLI selection is independent of Desktop package discovery. + +use std::ffi::OsStr; +use std::path::{Path, PathBuf}; + +use crate::{PlatformError, canonical_existing_file, validate_proxy_target}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CodexCliSource { + CommandLine, + Environment, + Packaged, + DesktopManagedCache, +} + +impl CodexCliSource { + pub const fn as_str(self) -> &'static str { + match self { + Self::CommandLine => "command-line", + Self::Environment => "environment", + Self::Packaged => "packaged", + Self::DesktopManagedCache => "desktop-managed-cache", + } + } +} + +/// A validated override captured before Desktop's CODEX_CLI_PATH becomes the Shim. +#[derive(Debug, Clone)] +pub struct CodexCliOverride { + pub(crate) executable: PathBuf, + pub(crate) source: CodexCliSource, +} + +/// Explicit options win over the inherited Desktop CLI selection. An invalid +/// selection is an error, never a reason to fall back to a packaged executable. +/// Empty environment values match Desktop's behavior and mean no override. +/// A managed caller already points CODEX_CLI_PATH at this installation's Shim; +/// recover its forwarded native target without treating the Shim as a new CLI. +pub fn resolve_codex_cli_override( + explicit: Option<&Path>, + inherited: Option<&OsStr>, + forwarded_stock: Option<&OsStr>, + shim: Option<&Path>, +) -> Result, PlatformError> { + let (path, source, label) = if let Some(path) = explicit { + (path, CodexCliSource::CommandLine, "--codex-cli") + } else if let Some(value) = inherited.filter(|value| !value.to_string_lossy().trim().is_empty()) + { + let inherited_path = Path::new(value); + let managed_caller = inherited_path.is_absolute() + && shim.is_some_and(|shim| { + inherited_path + .canonicalize() + .ok() + .zip(shim.canonicalize().ok()) + .is_some_and(|(inherited, shim)| inherited == shim) + }); + match forwarded_stock.filter(|_| managed_caller) { + Some(stock) => ( + Path::new(stock), + CodexCliSource::Environment, + "CODEXHOST_STOCK_CODEX_PATH", + ), + None => ( + inherited_path, + CodexCliSource::Environment, + "CODEX_CLI_PATH", + ), + } + } else { + return Ok(None); + }; + if !path.is_absolute() { + return Err(PlatformError::Invalid(format!( + "{label} must be an absolute executable path" + ))); + } + let executable = canonical_existing_file(path).map_err(|error| { + PlatformError::Invalid(format!("{label} '{}': {error}", path.display())) + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + if executable.metadata()?.permissions().mode() & 0o111 == 0 { + return Err(PlatformError::Invalid(format!( + "{label} '{}' is not executable", + path.display() + ))); + } + } + if let Some(shim) = shim { + validate_proxy_target(shim, &executable)?; + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let shim_metadata = shim.metadata()?; + let target_metadata = executable.metadata()?; + if shim_metadata.dev() == target_metadata.dev() + && shim_metadata.ino() == target_metadata.ino() + { + return Err(PlatformError::Invalid( + "native Codex CLI is a hard link to the Shim itself".into(), + )); + } + } + } + Ok(Some(CodexCliOverride { executable, source })) +} + +/// Default discovery is lazy: a valid override does not require a bundled CLI +/// or a Desktop-managed cache to be runnable or present. +pub(crate) fn select_codex_cli( + selected: Option<&CodexCliOverride>, + default_source: CodexCliSource, + default: impl FnOnce() -> Result, +) -> Result<(PathBuf, CodexCliSource), PlatformError> { + match selected { + Some(selected) => Ok((selected.executable.clone(), selected.source)), + None => Ok((default()?, default_source)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn executable(root: &Path, name: &str) -> PathBuf { + let path = root.join(name); + std::fs::write(&path, b"fixture").unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap(); + } + path.canonicalize().unwrap() + } + + #[test] + fn explicit_choice_wins_and_default_discovery_is_lazy() { + let root = crate::temporary_directory("cli-selection"); + let cli = executable(&root, "codex"); + let shim = executable(&root, "shim"); + let choice = + resolve_codex_cli_override(Some(&cli), Some(OsStr::new("invalid")), None, Some(&shim)) + .unwrap() + .unwrap(); + let (path, source) = select_codex_cli(Some(&choice), CodexCliSource::Packaged, || { + panic!("an explicit CLI must not consult the default") + }) + .unwrap(); + assert_eq!(path, cli); + assert_eq!(source, CodexCliSource::CommandLine); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn inherited_choice_is_canonicalized_and_empty_values_use_default() { + let root = crate::temporary_directory("cli-environment"); + let cli = executable(&root, "codex with spaces"); + let choice = resolve_codex_cli_override(None, Some(cli.as_os_str()), None, None) + .unwrap() + .unwrap(); + assert_eq!(choice.executable, cli); + assert_eq!(choice.source, CodexCliSource::Environment); + #[cfg(unix)] + { + let alias = root.join("current-codex"); + std::os::unix::fs::symlink(&cli, &alias).unwrap(); + let choice = resolve_codex_cli_override(None, Some(alias.as_os_str()), None, None) + .unwrap() + .unwrap(); + assert_eq!(choice.executable, cli); + } + for value in [None, Some(OsStr::new("")), Some(OsStr::new(" "))] { + assert!( + resolve_codex_cli_override(None, value, None, None) + .unwrap() + .is_none() + ); + } + let (path, source) = + select_codex_cli( + None, + CodexCliSource::DesktopManagedCache, + || Ok(cli.clone()), + ) + .unwrap(); + assert_eq!(path, cli); + assert_eq!(source, CodexCliSource::DesktopManagedCache); + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn invalid_explicit_choices_fail_without_fallback() { + let root = crate::temporary_directory("cli-invalid"); + let cli = executable(&root, "codex"); + for path in [ + PathBuf::from("relative"), + root.join("missing"), + root.clone(), + ] { + assert!( + resolve_codex_cli_override(Some(&path), Some(cli.as_os_str()), None, None).is_err() + ); + } + assert!( + resolve_codex_cli_override(None, Some(OsStr::new("relative")), None, None).is_err() + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&cli, std::fs::Permissions::from_mode(0o644)).unwrap(); + assert!(resolve_codex_cli_override(Some(&cli), None, None, None).is_err()); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn rejects_shim_itself_and_aliases() { + let root = crate::temporary_directory("cli-recursion"); + let shim = executable(&root, "shim"); + assert!(resolve_codex_cli_override(Some(&shim), None, None, Some(&shim)).is_err()); + #[cfg(unix)] + { + let alias = root.join("alias"); + std::os::unix::fs::symlink(&shim, &alias).unwrap(); + assert!( + resolve_codex_cli_override(None, Some(alias.as_os_str()), None, Some(&shim)) + .is_err() + ); + let hard_link = root.join("hard-link"); + std::fs::hard_link(&shim, &hard_link).unwrap(); + assert!(resolve_codex_cli_override(Some(&hard_link), None, None, Some(&shim)).is_err()); + } + std::fs::remove_dir_all(root).unwrap(); + } + + #[test] + fn managed_reentry_preserves_native_target_without_accepting_explicit_recursion() { + let root = crate::temporary_directory("cli-managed-reentry"); + let shim = executable(&root, "shim"); + let cli = executable(&root, "codex"); + let selected = resolve_codex_cli_override( + None, + Some(shim.as_os_str()), + Some(cli.as_os_str()), + Some(&shim), + ) + .unwrap() + .unwrap(); + assert_eq!(selected.executable, cli); + assert_eq!(selected.source, CodexCliSource::Environment); + assert!( + resolve_codex_cli_override( + Some(&shim), + Some(shim.as_os_str()), + Some(cli.as_os_str()), + Some(&shim) + ) + .is_err() + ); + assert!( + resolve_codex_cli_override( + None, + Some(shim.as_os_str()), + Some(shim.as_os_str()), + Some(&shim) + ) + .is_err() + ); + assert!( + resolve_codex_cli_override( + None, + Some(shim.as_os_str()), + Some(OsStr::new("missing")), + Some(&shim) + ) + .is_err() + ); + let unrelated_stock = root.join("absent"); + let selected = resolve_codex_cli_override( + None, + Some(cli.as_os_str()), + Some(unrelated_stock.as_os_str()), + Some(&shim), + ) + .unwrap() + .unwrap(); + assert_eq!(selected.executable, cli); + assert!( + resolve_codex_cli_override(None, None, Some(cli.as_os_str()), Some(&shim)) + .unwrap() + .is_none() + ); + std::fs::remove_dir_all(root).unwrap(); + } +} diff --git a/crates/platform/src/desktop_launch.rs b/crates/platform/src/desktop_launch.rs index f204d136f..cfde90a30 100644 --- a/crates/platform/src/desktop_launch.rs +++ b/crates/platform/src/desktop_launch.rs @@ -149,6 +149,7 @@ mod node_repl_override_tests { desktop_executable: directory.join("Desktop.exe"), packaged_codex_cli: directory.join("codex.exe"), executable_codex_cli: directory.join("codex.exe"), + codex_cli_source: crate::CodexCliSource::Packaged, }; let environment = managed_desktop_environment(&installation, &shim, &[]).unwrap(); let block = super::super::windows_desktop::windows_environment_block(&environment) @@ -982,6 +983,7 @@ mod tests { desktop_executable: "/usr/lib/chatgpt/ChatGPT".into(), packaged_codex_cli: "/usr/lib/chatgpt/resources/codex".into(), executable_codex_cli: "/usr/lib/chatgpt/resources/codex".into(), + codex_cli_source: crate::CodexCliSource::Packaged, } } @@ -1039,6 +1041,36 @@ mod tests { ); } + #[cfg(target_os = "linux")] + #[test] + fn managed_launch_routes_desktop_through_shim_and_native_requests_to_selected_cli() { + let mut installation = linux_installation(); + installation.executable_codex_cli = "/independent/codex".into(); + installation.codex_cli_source = crate::CodexCliSource::CommandLine; + let shim = Path::new("/usr/bin/true").canonicalize().unwrap(); + let command = desktop_launch_command( + &installation, + &shim, + DesktopLaunchMode::DirectExecutable, + &[], + &[], + ) + .unwrap(); + let environment = command.get_envs().collect::>(); + assert!(environment.contains(&( + std::ffi::OsStr::new("CODEX_CLI_PATH"), + Some(shim.as_os_str()) + ))); + assert!(environment.contains(&( + std::ffi::OsStr::new("CODEXHOST_STOCK_CODEX_PATH"), + Some(installation.executable_codex_cli.as_os_str()) + ))); + assert_ne!( + installation.packaged_codex_cli, + installation.executable_codex_cli + ); + } + #[cfg(target_os = "linux")] #[test] fn managed_launch_supervises_the_desktop_executable_directly() { @@ -1253,6 +1285,7 @@ mod tests { desktop_executable: "/Applications/ChatGPT.app/Contents/MacOS/ChatGPT".into(), packaged_codex_cli: "/Applications/ChatGPT.app/Contents/Resources/codex".into(), executable_codex_cli: "/Applications/ChatGPT.app/Contents/Resources/codex".into(), + codex_cli_source: crate::CodexCliSource::Packaged, }; let command = desktop_launch_command( &installation, diff --git a/crates/platform/src/installation.rs b/crates/platform/src/installation.rs index 5a8d2ecb4..57c8483ef 100644 --- a/crates/platform/src/installation.rs +++ b/crates/platform/src/installation.rs @@ -20,6 +20,10 @@ use windows::Management::Deployment::PackageManager; #[cfg(target_os = "windows")] use windows::core::HSTRING; +#[cfg(not(target_os = "linux"))] +use super::CodexCliOverride; +#[cfg(any(target_os = "windows", target_os = "macos"))] +use super::CodexCliSource; #[cfg(any(target_os = "windows", target_os = "macos"))] use super::DesktopIdentity; #[cfg(not(target_os = "linux"))] @@ -29,6 +33,8 @@ use super::PlatformError; use super::WindowsAppxActivationIdentity; #[cfg(target_os = "windows")] use super::canonical_existing_file; +#[cfg(any(target_os = "windows", target_os = "macos"))] +use super::codex_cli::select_codex_cli; #[cfg(any(target_os = "windows", target_os = "macos", target_os = "linux"))] pub(super) fn sha256_file(path: &Path) -> Result { @@ -268,9 +274,19 @@ fn find_desktop_cli_cache(local_app_data: &Path) -> Result, Plat } #[cfg(target_os = "windows")] +#[cfg(test)] fn windows_installation( details: WindowsPackageDetails, local_app_data: &Path, +) -> Result { + windows_installation_with_cli(details, || Ok(local_app_data.to_path_buf()), None) +} + +#[cfg(target_os = "windows")] +fn windows_installation_with_cli( + details: WindowsPackageDetails, + local_app_data: impl FnOnce() -> Result, + selected_cli: Option<&CodexCliOverride>, ) -> Result { let install_root = details.install_root.canonicalize().map_err(|error| { PlatformError::NotFound(format!( @@ -281,20 +297,31 @@ fn windows_installation( let desktop_executable = install_root.join("app/ChatGPT.exe"); let packaged_codex_cli = install_root.join("app/resources/codex.exe"); let asar_path = install_root.join("app/resources/app.asar"); - if !desktop_executable.is_file() || !packaged_codex_cli.is_file() || !asar_path.is_file() { + if !desktop_executable.is_file() || !asar_path.is_file() { return Err(PlatformError::NotFound(format!( - "Codex Desktop package '{}' does not contain the required executable, CLI, and app.asar resources", + "Codex Desktop package '{}' does not contain the required executable and app.asar resources", install_root.display() ))); } // WindowsApps resources are not directly executable by a normal desktop // process. Use any Desktop-managed CLI copy, without requiring it to match // the bundled CLI byte-for-byte or by version. - let executable_codex_cli = find_desktop_cli_cache(local_app_data)?.ok_or_else(|| { - PlatformError::NotFound( - "no runnable Codex CLI was found in the Desktop-managed cache; launch the official Desktop once to create it".into(), - ) - })?; + let (executable_codex_cli, codex_cli_source) = select_codex_cli( + selected_cli, + CodexCliSource::DesktopManagedCache, + || { + if !packaged_codex_cli.is_file() { + return Err(PlatformError::NotFound( + "packaged Codex CLI is unavailable".into(), + )); + } + find_desktop_cli_cache(&local_app_data()?)?.ok_or_else(|| { + PlatformError::NotFound( + "no runnable Codex CLI was found in the Desktop-managed cache; launch the official Desktop once to create it".into(), + ) + }) + }, + )?; Ok(DesktopInstallation { identity: DesktopIdentity::WindowsPackage { @@ -310,6 +337,7 @@ fn windows_installation( desktop_executable, packaged_codex_cli, executable_codex_cli, + codex_cli_source, }) } @@ -425,8 +453,15 @@ mod desktop_managed_cli_tests { #[cfg(target_os = "windows")] pub fn discover_codex_desktop() -> Result { + discover_codex_desktop_with_cli(None) +} + +#[cfg(target_os = "windows")] +pub fn discover_codex_desktop_with_cli( + selected_cli: Option<&CodexCliOverride>, +) -> Result { if let Some(details) = probe_package_details(env::var_os)? { - return windows_installation(details, &windows_local_app_data()?); + return windows_installation_with_cli(details, windows_local_app_data, selected_cli); } // A portable installation - an MSIX that was extracted rather than // installed - registers no AppX package, so the PackageManager lookup below @@ -434,10 +469,10 @@ pub fn discover_codex_desktop() -> Result { // keeps every entry point working, including the argument-less `codexhost` // and `codexhost inspect`. if let Some(root) = custom_install_root(env::var_os) { - return discover_codex_desktop_from_root(&root); + return discover_codex_desktop_from_root_with_cli(&root, selected_cli); } let details = discover_installed_windows_package()?; - windows_installation(details, &windows_local_app_data()?) + windows_installation_with_cli(details, windows_local_app_data, selected_cli) } /// Best-effort read of the packaged Desktop version from `AppxManifest.xml`. @@ -476,6 +511,14 @@ fn portable_package_version(package_root: &Path) -> Option { #[cfg(target_os = "windows")] pub fn discover_codex_desktop_from_root( install_root: &Path, +) -> Result { + discover_codex_desktop_from_root_with_cli(install_root, None) +} + +#[cfg(target_os = "windows")] +pub fn discover_codex_desktop_from_root_with_cli( + install_root: &Path, + selected_cli: Option<&CodexCliOverride>, ) -> Result { let supplied_root = install_root.canonicalize().map_err(|error| { PlatformError::NotFound(format!( @@ -501,13 +544,16 @@ pub fn discover_codex_desktop_from_root( let desktop_executable = app_root.join("ChatGPT.exe"); let packaged_codex_cli = app_root.join("resources/codex.exe"); let asar_path = app_root.join("resources/app.asar"); - if !packaged_codex_cli.is_file() || !asar_path.is_file() { + if !asar_path.is_file() { return Err(PlatformError::NotFound(format!( - "Codex Desktop directory '{}' does not contain the required codex.exe and app.asar resources beside ChatGPT.exe", + "Codex Desktop directory '{}' does not contain the required app.asar resource beside ChatGPT.exe", supplied_root.display() ))); } - let executable_codex_cli = packaged_codex_cli.clone(); + let (executable_codex_cli, codex_cli_source) = + select_codex_cli(selected_cli, CodexCliSource::Packaged, || { + canonical_existing_file(&packaged_codex_cli) + })?; let version = portable_package_version(&package_root).unwrap_or_else(|| "0.0.0.0".to_owned()); @@ -525,6 +571,7 @@ pub fn discover_codex_desktop_from_root( desktop_executable, packaged_codex_cli, executable_codex_cli, + codex_cli_source, }) } @@ -535,6 +582,56 @@ mod windows_tests { use std::fs; use std::path::PathBuf; + #[test] + fn explicit_cli_skips_packaged_cli_and_desktop_cache_requirements() { + let root = crate::temporary_directory("windows-external-cli"); + let package = root.join("package"); + fs::create_dir_all(package.join("app/resources")).unwrap(); + fs::write(package.join("app/ChatGPT.exe"), b"desktop").unwrap(); + fs::write(package.join("app/resources/app.asar"), b"asar").unwrap(); + let cli = root.join("external-codex.exe"); + fs::write(&cli, b"cli").unwrap(); + let selected = crate::resolve_codex_cli_override(Some(&cli), None, None, None) + .unwrap() + .unwrap(); + let installation = super::windows_installation_with_cli( + super::WindowsPackageDetails { + package_name: "OpenAI.Codex".into(), + package_family_name: "OpenAI.Codex_family".into(), + appx_activation: crate::WindowsAppxActivationIdentity { + package_full_name: "OpenAI.Codex_1.2.3.4_x64_family".into(), + app_user_model_id: "OpenAI.Codex_family!App".into(), + }, + version: "1.2.3.4".into(), + install_root: package.clone(), + }, + || panic!("explicit CLI must not consult LOCALAPPDATA"), + Some(&selected), + ) + .unwrap(); + assert_eq!( + installation.executable_codex_cli, + cli.canonicalize().unwrap() + ); + assert_eq!( + installation.codex_cli_source, + crate::CodexCliSource::CommandLine + ); + assert!(!installation.packaged_codex_cli.exists()); + let portable = + super::discover_codex_desktop_from_root_with_cli(&package, Some(&selected)).unwrap(); + assert_eq!( + portable.executable_codex_cli, + installation.executable_codex_cli + ); + assert!(super::discover_codex_desktop_from_root(&package).is_err()); + fs::remove_file(package.join("app/resources/app.asar")).unwrap(); + assert!( + super::discover_codex_desktop_from_root_with_cli(&package, Some(&selected)).is_err() + ); + fs::remove_dir_all(root).unwrap(); + } + use super::{WindowsPackageDetails, probe_package_details, windows_installation}; use crate::{ CUSTOM_INSTALL_ROOT_ENV, PROBE_APP_USER_MODEL_ID_ENV, PROBE_DESKTOP_VERSION_ENV, @@ -899,6 +996,14 @@ fn canonical_macho_executable(path: &Path, label: &str) -> Result Result { + inspect_bundle_with_cli(bundle, None) +} + +#[cfg(target_os = "macos")] +fn inspect_bundle_with_cli( + bundle: &Path, + selected_cli: Option<&CodexCliOverride>, +) -> Result { let bundle = bundle.canonicalize().map_err(|error| { PlatformError::NotFound(format!( "Codex App bundle '{}' is unavailable: {error}", @@ -947,13 +1052,24 @@ fn inspect_bundle(bundle: &Path) -> Result { { cli_path = bundle.join("Contents/Resources/codex"); } - let packaged_codex_cli = canonical_macho_executable(&cli_path, "Codex CLI")?; - if !desktop_executable.starts_with(&bundle) || !packaged_codex_cli.starts_with(&bundle) { + let packaged_codex_cli = cli_path; + if !desktop_executable.starts_with(&bundle) { return Err(PlatformError::Invalid(format!( - "App bundle '{}' resolves an executable outside the bundle", + "App bundle '{}' resolves a Desktop executable outside the bundle", bundle.display() ))); } + let (executable_codex_cli, codex_cli_source) = + select_codex_cli(selected_cli, CodexCliSource::Packaged, || { + let cli = canonical_macho_executable(&packaged_codex_cli, "Codex CLI")?; + if !cli.starts_with(&bundle) { + return Err(PlatformError::Invalid(format!( + "App bundle '{}' resolves a Codex CLI outside the bundle", + bundle.display() + ))); + } + Ok(cli) + })?; Ok(DesktopInstallation { identity: DesktopIdentity::MacOsBundle { @@ -965,14 +1081,24 @@ fn inspect_bundle(bundle: &Path) -> Result { install_root: bundle, desktop_launcher: desktop_executable.clone(), desktop_executable, - packaged_codex_cli: packaged_codex_cli.clone(), - executable_codex_cli: packaged_codex_cli, + packaged_codex_cli, + executable_codex_cli, + codex_cli_source, }) } #[cfg(target_os = "macos")] +#[cfg(test)] fn discover_from_candidates( candidates: impl IntoIterator, +) -> Result { + discover_from_candidates_with_cli(candidates, None) +} + +#[cfg(target_os = "macos")] +fn discover_from_candidates_with_cli( + candidates: impl IntoIterator, + selected_cli: Option<&CodexCliOverride>, ) -> Result { let mut installations = Vec::new(); let mut invalid = Vec::new(); @@ -980,7 +1106,7 @@ fn discover_from_candidates( if !candidate.exists() { continue; } - match inspect_bundle(&candidate) { + match inspect_bundle_with_cli(&candidate, selected_cli) { Ok(installation) => installations.push(installation), Err(error) => invalid.push(format!("{}: {error}", candidate.display())), } @@ -1006,30 +1132,51 @@ fn discover_from_candidates( } #[cfg(target_os = "macos")] +#[cfg(test)] fn discover_with_custom_root( custom_root: Option, candidates: impl FnOnce() -> Vec, +) -> Result { + discover_with_custom_root_with_cli(custom_root, candidates, None) +} + +#[cfg(target_os = "macos")] +fn discover_with_custom_root_with_cli( + custom_root: Option, + candidates: impl FnOnce() -> Vec, + selected_cli: Option<&CodexCliOverride>, ) -> Result { match custom_root { - Some(root) => inspect_bundle(&root), - None => discover_from_candidates(candidates()), + Some(root) => inspect_bundle_with_cli(&root, selected_cli), + None => discover_from_candidates_with_cli(candidates(), selected_cli), } } #[cfg(target_os = "macos")] pub fn discover_codex_desktop() -> Result { - discover_with_custom_root(custom_install_root(env::var_os), || { - let mut candidates = vec![ - PathBuf::from("/Applications/Codex.app"), - PathBuf::from("/Applications/ChatGPT.app"), - ]; - if let Some(home) = std::env::var_os("HOME") { - let applications = PathBuf::from(home).join("Applications"); - candidates.push(applications.join("Codex.app")); - candidates.push(applications.join("ChatGPT.app")); - } - candidates - }) + discover_codex_desktop_with_cli(None) +} + +#[cfg(target_os = "macos")] +pub fn discover_codex_desktop_with_cli( + selected_cli: Option<&CodexCliOverride>, +) -> Result { + discover_with_custom_root_with_cli( + custom_install_root(env::var_os), + || { + let mut candidates = vec![ + PathBuf::from("/Applications/Codex.app"), + PathBuf::from("/Applications/ChatGPT.app"), + ]; + if let Some(home) = std::env::var_os("HOME") { + let applications = PathBuf::from(home).join("Applications"); + candidates.push(applications.join("Codex.app")); + candidates.push(applications.join("ChatGPT.app")); + } + candidates + }, + selected_cli, + ) } /// Resolve a helper's official CLI from a validated Desktop bundle, never PATH. @@ -1046,7 +1193,15 @@ pub fn discover_desktop_managed_codex_cli() -> Result { /// Inspect an explicit macOS bundle without selecting another installation. #[cfg(target_os = "macos")] pub fn discover_codex_desktop_from_root(root: &Path) -> Result { - inspect_bundle(root) + discover_codex_desktop_from_root_with_cli(root, None) +} + +#[cfg(target_os = "macos")] +pub fn discover_codex_desktop_from_root_with_cli( + root: &Path, + selected_cli: Option<&CodexCliOverride>, +) -> Result { + inspect_bundle_with_cli(root, selected_cli) } #[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] @@ -1056,6 +1211,13 @@ pub fn discover_codex_desktop() -> Result { )) } +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +pub fn discover_codex_desktop_with_cli( + _selected_cli: Option<&CodexCliOverride>, +) -> Result { + discover_codex_desktop() +} + #[cfg(all(test, target_os = "macos"))] mod tests { use std::fs; @@ -1106,6 +1268,31 @@ mod tests { bundle } + #[test] + fn external_cli_skips_bundled_cli_without_relaxing_bundle_identity() { + let bundle = temporary_bundle("ChatGPT.app", "com.openai.codex", false); + let cli = bundle.parent().unwrap().join("external-codex"); + fs::copy(bundle.join("Contents/MacOS/ChatGPT"), &cli).unwrap(); + let selected = crate::resolve_codex_cli_override(None, Some(cli.as_os_str()), None, None) + .unwrap() + .unwrap(); + let installation = super::inspect_bundle_with_cli(&bundle, Some(&selected)).unwrap(); + assert_eq!( + installation.executable_codex_cli, + cli.canonicalize().unwrap() + ); + assert_eq!( + installation.codex_cli_source, + crate::CodexCliSource::Environment + ); + assert!(!installation.packaged_codex_cli.exists()); + assert!(super::inspect_bundle(&bundle).is_err()); + let invalid = temporary_bundle("Other.app", "com.example.other", false); + assert!(super::inspect_bundle_with_cli(&invalid, Some(&selected)).is_err()); + fs::remove_dir_all(bundle.parent().unwrap()).unwrap(); + fs::remove_dir_all(invalid.parent().unwrap()).unwrap(); + } + #[test] fn discovers_a_valid_macos_bundle_under_current_or_legacy_app_names() { for app_name in ["Codex.app", "ChatGPT.app"] { diff --git a/crates/platform/src/lib.rs b/crates/platform/src/lib.rs index 12ab780d2..77b189e35 100644 --- a/crates/platform/src/lib.rs +++ b/crates/platform/src/lib.rs @@ -10,6 +10,7 @@ use std::path::{Path, PathBuf}; mod background; #[cfg(not(target_os = "windows"))] mod background; +mod codex_cli; mod desktop_launch; mod installation; #[cfg(target_os = "linux")] @@ -37,20 +38,23 @@ mod windows_proxy; mod windows_ui; pub use background::detach_from_terminal; +pub use codex_cli::{CodexCliOverride, CodexCliSource, resolve_codex_cli_override}; pub use desktop_launch::{ DesktopProcess, launch_desktop, launch_stock_desktop, open_external_url, open_latest_codexhost_release, }; #[cfg(any(target_os = "macos", target_os = "linux"))] pub use desktop_launch::{DesktopSession, launch_desktop_session}; -#[cfg(not(target_os = "linux"))] -pub use installation::discover_codex_desktop; #[cfg(any(target_os = "windows", target_os = "macos"))] pub use installation::discover_codex_desktop_from_root; #[cfg(any(target_os = "windows", target_os = "macos"))] +pub use installation::discover_codex_desktop_from_root_with_cli; +#[cfg(any(target_os = "windows", target_os = "macos"))] pub use installation::discover_desktop_managed_codex_cli; +#[cfg(not(target_os = "linux"))] +pub use installation::{discover_codex_desktop, discover_codex_desktop_with_cli}; #[cfg(target_os = "linux")] -pub use linux_installation::discover_codex_desktop; +pub use linux_installation::{discover_codex_desktop, discover_codex_desktop_with_cli}; pub use macos_native_harness_broker::{ NATIVE_HARNESS_BROKER_LABEL, NativeHarnessBrokerCommand, NativeHarnessBrokerInstallStep, NativeHarnessBrokerLaunchAgentPlan, NativeHarnessBrokerLaunchctlPlan, @@ -160,6 +164,7 @@ pub struct DesktopInstallation { pub desktop_executable: PathBuf, pub packaged_codex_cli: PathBuf, pub executable_codex_cli: PathBuf, + pub codex_cli_source: CodexCliSource, } #[derive(Debug)] diff --git a/crates/platform/src/linux_installation.rs b/crates/platform/src/linux_installation.rs index d12c24f23..85dc883de 100644 --- a/crates/platform/src/linux_installation.rs +++ b/crates/platform/src/linux_installation.rs @@ -4,8 +4,11 @@ use std::path::{Path, PathBuf}; use serde::Deserialize; +use super::codex_cli::select_codex_cli; use super::installation::{canonical_unix_executable, sha256_file}; -use crate::{DesktopIdentity, DesktopInstallation, PlatformError}; +use crate::{ + CodexCliOverride, CodexCliSource, DesktopIdentity, DesktopInstallation, PlatformError, +}; const LINUX_INSTALL_ROOT: &str = "/usr/lib/chatgpt"; const LINUX_DESKTOP_LAUNCHER: &str = "/usr/bin/chatgpt"; @@ -67,6 +70,14 @@ fn canonical_linux_elf(path: &Path, label: &str) -> Result Result { + linux_installation_with_cli(root, launcher_path, None) +} + +fn linux_installation_with_cli( + root: &Path, + launcher_path: &Path, + selected_cli: Option<&CodexCliOverride>, ) -> Result { let install_root = root.canonicalize().map_err(|error| { PlatformError::NotFound(format!( @@ -120,17 +131,26 @@ fn linux_installation( canonical_linux_elf(&install_root.join("ChatGPT"), "Desktop executable")?; let packaged_launcher = canonical_unix_executable(&install_root.join("codex-launcher"), "Desktop launcher")?; - let packaged_codex_cli = - canonical_linux_elf(&install_root.join("resources/codex"), "Codex CLI")?; + let packaged_codex_cli = install_root.join("resources/codex"); if !desktop_executable.starts_with(&install_root) || !packaged_launcher.starts_with(&install_root) - || !packaged_codex_cli.starts_with(&install_root) { return Err(PlatformError::Invalid(format!( "ChatGPT Linux package '{}' resolves an executable outside the package", install_root.display() ))); } + let (executable_codex_cli, codex_cli_source) = + select_codex_cli(selected_cli, CodexCliSource::Packaged, || { + let cli = canonical_linux_elf(&packaged_codex_cli, "Codex CLI")?; + if !cli.starts_with(&install_root) { + return Err(PlatformError::Invalid(format!( + "ChatGPT Linux package '{}' resolves a Codex CLI outside the package", + install_root.display() + ))); + } + Ok(cli) + })?; let launcher_metadata = launcher_path.symlink_metadata().map_err(|error| { PlatformError::NotFound(format!( "official ChatGPT launcher '{}' is unavailable: {error}", @@ -173,8 +193,9 @@ fn linux_installation( install_root, desktop_launcher, desktop_executable, - packaged_codex_cli: packaged_codex_cli.clone(), - executable_codex_cli: packaged_codex_cli, + packaged_codex_cli, + executable_codex_cli, + codex_cli_source, }) } @@ -185,6 +206,16 @@ pub fn discover_codex_desktop() -> Result { ) } +pub fn discover_codex_desktop_with_cli( + selected_cli: Option<&CodexCliOverride>, +) -> Result { + linux_installation_with_cli( + Path::new(LINUX_INSTALL_ROOT), + Path::new(LINUX_DESKTOP_LAUNCHER), + selected_cli, + ) +} + #[cfg(test)] mod tests { use std::fs; @@ -263,10 +294,51 @@ mod tests { installation.packaged_codex_cli, installation.executable_codex_cli ); + assert_eq!( + installation.codex_cli_source, + crate::CodexCliSource::Packaged + ); fs::remove_dir_all(root).expect("remove fixture"); fs::remove_file(launcher).expect("remove launcher"); } + #[test] + fn external_cli_does_not_require_a_packaged_cli_but_still_validates_desktop() { + let root = fixture("chatgpt", "prod"); + let official_launcher = launcher(&root); + let external_root = temporary_directory("external-native-cli"); + let external_cli = external_root.join("codex"); + fs::write(&external_cli, native_elf()).unwrap(); + fs::set_permissions(&external_cli, fs::Permissions::from_mode(0o755)).unwrap(); + let selected = crate::resolve_codex_cli_override(Some(&external_cli), None, None, None) + .unwrap() + .unwrap(); + let packaged = root.join("resources/codex"); + fs::remove_file(&packaged).unwrap(); + + assert!(linux_installation(&root, &official_launcher).is_err()); + let installation = + super::linux_installation_with_cli(&root, &official_launcher, Some(&selected)).unwrap(); + assert_eq!(installation.packaged_codex_cli, packaged); + assert_eq!(installation.executable_codex_cli, external_cli); + assert_eq!( + installation.codex_cli_source, + crate::CodexCliSource::CommandLine + ); + + fs::write( + root.join("resources/linux-package-metadata.json"), + r#"{"codexAppBrand":"codex","codexBuildFlavor":"dev","version":"26.803.81509"}"#, + ) + .unwrap(); + assert!( + super::linux_installation_with_cli(&root, &official_launcher, Some(&selected)).is_err() + ); + fs::remove_dir_all(root).unwrap(); + fs::remove_dir_all(external_root).unwrap(); + fs::remove_file(official_launcher).unwrap(); + } + #[test] fn rejects_symlinked_package_metadata() { let root = fixture("chatgpt", "prod"); diff --git a/docs/architecture/native-codex-cli-selection.md b/docs/architecture/native-codex-cli-selection.md new file mode 100644 index 000000000..ce54a473b --- /dev/null +++ b/docs/architecture/native-codex-cli-selection.md @@ -0,0 +1,50 @@ +# 原生 Codex CLI 选择 / Native Codex CLI selection + +codexhost 分别识别 Codex Desktop 安装和选择原生 Codex CLI。Desktop 的包身份、版本、启动器和 Renderer 资源仍由平台探测校验;原生 CLI 可以位于 Desktop 安装之外。外部 Harness 继续使用各自的 Adapter 和原生 CLI,这个选择只决定原生 Codex 请求及 CLI 命令的转发目标。 + +codexhost discovers the Codex Desktop installation and selects the native Codex CLI separately. Platform discovery still validates Desktop identity, version, launcher, and Renderer resources; the native CLI can reside outside that installation. External Harnesses continue to use their own Adapters and native CLIs. This selection determines the forwarding target for native Codex requests and CLI commands. + +## 配置与优先级 / Configuration and precedence + +```bash +codexhost launch --codex-cli /absolute/path/to/codex +codexhost inspect --codex-cli /absolute/path/to/codex +codexhost inspect --json --codex-cli /absolute/path/to/codex +``` + +也可以通过 `CODEX_CLI_PATH` 配置,适用于无参数的 `codexhost` 启动。Launcher 在将 Desktop 的 `CODEX_CLI_PATH` 替换为 Shim 之前读取该值。这里读取的是 Launcher 自身的启动环境;其他 `.desktop` 文件或包装脚本中的配置不会自动导入。 + +Alternatively, set `CODEX_CLI_PATH`, including for argumentless `codexhost` launches. The Launcher captures this value before replacing Desktop's `CODEX_CLI_PATH` with the Shim. It reads its own launch environment; settings inside another `.desktop` file or wrapper script are not imported automatically. + +| 优先级 / Priority | 来源 / Source | `inspect` 来源值 / Source value | +| --- | --- | --- | +| 1 | `--codex-cli` | `command-line` | +| 2 | 非空的继承 `CODEX_CLI_PATH` / Nonempty inherited `CODEX_CLI_PATH` | `environment` | +| 3 | Linux/macOS 和 Windows 解包安装的包内 CLI / Packaged CLI on Linux/macOS and portable Windows | `packaged` | +| 3 | Windows AppX 的 Desktop 管理缓存 / Desktop-managed cache for Windows AppX | `desktop-managed-cache` | + +显式选择必须是现存的绝对文件路径;Unix 上必须有执行权限。符号链接会解析为实际文件。选择不得指向当前安装的 Shim,包括符号链接别名和 Unix 硬链接。空白环境值表示未指定。无效的显式选择会报错,不会回退、搜索 `PATH`、下载或安装 CLI。 + +An override must name an existing absolute file path, with executable permission on Unix. Symlinks resolve to their target files. The selection must not point to the current installation's Shim, including symlink aliases and Unix hard links. A blank environment value means no override. Invalid overrides produce an error instead of falling back, searching `PATH`, downloading, or installing a CLI. + +从受管终端再次调用 Launcher 时,如果继承的 `CODEX_CLI_PATH` 解析为当前安装的 Shim,就使用继承的 `CODEXHOST_STOCK_CODEX_PATH` 恢复原生目标并校验,来源记为 `environment`。该恢复不覆盖显式 `--codex-cli`,也不会在普通 CLI 环境中采用无关的私有变量。 + +When invoking the Launcher again from a managed terminal, an inherited `CODEX_CLI_PATH` that resolves to this installation's Shim uses the inherited `CODEXHOST_STOCK_CODEX_PATH` to recover and validate the native target, with source `environment`. This recovery does not override an explicit `--codex-cli` or adopt unrelated private variables in a normal CLI environment. + +显式 CLI 不要求包内 CLI 或 Windows Desktop 缓存存在。未指定时保留各平台原有的默认查找和校验。路径校验不等于版本或协议兼容性测试;用户选择的 CLI 仍需兼容当前 Desktop 的 app-server 协议。 + +An explicit CLI does not require a packaged CLI or Windows Desktop cache to exist. Without an override, each platform retains its existing default lookup and validation. Path validation is not a version or protocol compatibility test; the selected CLI must still support the current Desktop's app-server protocol. + +## 启动与诊断 / Launch and diagnostics + +受管启动继续设置 `CODEX_CLI_PATH=` 和 `CODEXHOST_STOCK_CODEX_PATH=<选中的原生 CLI>`。Shim 和 Host 通过后者转发原生请求;没有修改 Harness Adapter、Model、Provider 或账号路由。此选择只适用于本地 Desktop,不配置远程 SSH Host。 + +Managed launch continues to set `CODEX_CLI_PATH=` and `CODEXHOST_STOCK_CODEX_PATH=`. The Shim and Host use the latter to forward native requests. Harness Adapters, Model, Provider, and account routing are unaffected. The selection applies to the local Desktop and does not configure remote SSH Hosts. + +`inspect` 文本输出包含 `packaged_codex_cli`、`executable_codex_cli` 和 `codex_cli_source`。JSON 的 `desktop` 对象对应增加 `packagedCodexCli`、`executableCodexCli` 和 `codexCliSource`;这是 schema v1 的附加字段,旧 Launcher 不提供这些字段。包内路径是资源位置,显式选择时该文件可能不存在。`inspect` 描述本次调用的选择;对已经运行的受管 Desktop 再次启动只会沿用既有实例,不会热切换它的 CLI。 + +Text `inspect` output includes `packaged_codex_cli`, `executable_codex_cli`, and `codex_cli_source`. The JSON `desktop` object adds `packagedCodexCli`, `executableCodexCli`, and `codexCliSource` as additive schema v1 fields; older launchers omit them. The packaged path identifies the resource location and may not exist when an override is selected. `inspect` describes the selection for that invocation. Launching again while a managed Desktop is running reuses that instance and does not switch its CLI live. + +该配置没有改变现有辅助工具在丢失私有环境变量时的恢复规则,也没有扩大受支持的 Desktop 安装格式或增加 AppImage 支持。 + +This configuration does not change existing helper recovery when private environment variables are lost, expand supported Desktop installation formats, or add AppImage support. diff --git a/docs/index.md b/docs/index.md index 5f51cb962..bc2a44b8b 100644 --- a/docs/index.md +++ b/docs/index.md @@ -111,6 +111,7 @@ | 文档 | 内容与阅读时机 | | --- | --- | +| [`architecture/native-codex-cli-selection.md`](architecture/native-codex-cli-selection.md) | 原生 Codex CLI 的独立选择、优先级、平台默认值和诊断;修改 Desktop 启动的 CLI 转发目标时阅读。 | | [`platforms/linux/linux.zh-CN.md`](platforms/linux/linux.zh-CN.md) | Linux 安装、兼容性、进程所有权和诊断的中文说明;维护 Linux 支持时阅读。 | | [`platforms/linux/linux.md`](platforms/linux/linux.md) | Linux 安装与诊断的英文说明;修改对应中文说明时同步核对。 | | [`platforms/remote/remote-ssh-host.zh-CN.md`](platforms/remote/remote-ssh-host.zh-CN.md) | SSH 远程使用教程:安装、添加连接、选择工具、更新和常见问题。 | diff --git a/docs/platforms/linux/linux.md b/docs/platforms/linux/linux.md index 279023de3..a6737fa67 100644 --- a/docs/platforms/linux/linux.md +++ b/docs/platforms/linux/linux.md @@ -17,6 +17,10 @@ The Linux release supports the official ChatGPT `.deb` and `.rpm` packages on x8 The runtime requires a mounted `/proc` and Linux `pidfd` support. Snap, Flatpak, AppImage, local or relocated installations, wrapper or `alternatives` launchers, cross-architecture execution, and codexhost Linux installer packages are not supported. codexhost is installed and updated through npm on Linux. +## Native Codex CLI + +Use `codexhost launch --codex-cli /absolute/path/to/codex` or set `CODEX_CLI_PATH` in the Launcher's environment to select the native Codex CLI independently. Without an override, the packaged CLI remains the default; an invalid override produces an error. This setting does not expand supported Desktop installation formats. See [Native Codex CLI selection](../../architecture/native-codex-cli-selection.md) for precedence, validation, and diagnostics. + ## Renderer compatibility Renderer integration failures are recovered in the background and do not display compatibility dialogs or write local warning acknowledgements. While an external Agent integration is unavailable, the managed Desktop remains usable with official Codex routing. The initial Controller handshake still fails closed on malformed or unsupported readiness output. diff --git a/docs/platforms/linux/linux.zh-CN.md b/docs/platforms/linux/linux.zh-CN.md index 01b4d5856..5abd4e638 100644 --- a/docs/platforms/linux/linux.zh-CN.md +++ b/docs/platforms/linux/linux.zh-CN.md @@ -17,6 +17,10 @@ Linux 版本支持 x86-64 和 ARM64 上的官方 ChatGPT `.deb` 和 `.rpm` 包 运行时要求 `/proc` 已挂载,并且 Linux 支持 `pidfd`。目前不支持 Snap、Flatpak、AppImage、本地或迁移后的安装、包装脚本或 `alternatives` 启动器、跨架构执行,以及 codexhost Linux installer 包。codexhost 在 Linux 上通过 npm 安装和更新。 +## 原生 Codex CLI + +可以使用 `codexhost launch --codex-cli /absolute/path/to/codex` 或在 Launcher 的环境中设置 `CODEX_CLI_PATH`,独立选择原生 Codex CLI。未指定时仍使用包内 CLI;显式选择无效时直接报错。该设置不改变 Desktop 安装格式的支持范围。优先级、校验和诊断见[原生 Codex CLI 选择](../../architecture/native-codex-cli-selection.md)。 + ## Renderer 兼容性 Renderer 集成失败会在后台恢复,不会显示兼容弹窗,也不会写入本地警告确认。外部 Agent 集成不可用时,受管 Desktop 仍可通过官方 Codex 路由使用。Controller 的首次握手仍会对格式错误或不受支持的 readiness 输出按失败关闭处理。 diff --git a/packages/console-server/src/installation.ts b/packages/console-server/src/installation.ts index 812aefae6..08880d25b 100644 --- a/packages/console-server/src/installation.ts +++ b/packages/console-server/src/installation.ts @@ -12,6 +12,10 @@ export interface InspectDesktop { version: string; build: string; installRoot: string; + /** Optional for compatibility with older launchers using inspect schema v1. */ + packagedCodexCli?: string; + executableCodexCli?: string; + codexCliSource?: "command-line" | "environment" | "packaged" | "desktop-managed-cache"; processIds: number[]; } diff --git a/packages/console-server/test/installation.test.ts b/packages/console-server/test/installation.test.ts index b42be2bd7..8b9d5bc55 100644 --- a/packages/console-server/test/installation.test.ts +++ b/packages/console-server/test/installation.test.ts @@ -86,4 +86,32 @@ describe("console installation", () => { it("rejects inspect output from an unknown schema", () => { expect(() => parseInspectDocument({ schemaVersion: 2, runtime: {} })).toThrow("schema"); }); + + it("preserves CLI selection diagnostics while accepting older schema v1 launchers", () => { + const document = { + schemaVersion: 1, + launcherVersion: "1.0.0", + launcherExecutable: "/installed/codexhost", + desktop: { + platform: "linux", + version: "1.2.3", + build: "1.2.3", + installRoot: "/usr/lib/chatgpt", + processIds: [], + }, + desktopError: null, + runtime: { descriptorPath: null, running: false, launcherPid: null }, + }; + expect(parseInspectDocument(document).desktop?.executableCodexCli).toBeUndefined(); + const selected = { + ...document, + desktop: { + ...document.desktop, + packagedCodexCli: "/usr/lib/chatgpt/resources/codex", + executableCodexCli: "/independent/codex", + codexCliSource: "command-line", + }, + }; + expect(parseInspectDocument(selected).desktop).toEqual(selected.desktop); + }); });