diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 063f513d11..ee27df0537 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2291,6 +2291,7 @@ dependencies = [ "codex-config", "codex-core", "codex-core-plugins", + "codex-desktop-distribution", "codex-exec", "codex-exec-server", "codex-execpolicy", @@ -2782,6 +2783,16 @@ dependencies = [ "zip 2.4.2", ] +[[package]] +name = "codex-desktop-distribution" +version = "0.0.0" +dependencies = [ + "codex-utils-absolute-path", + "pretty_assertions", + "tempfile", + "thiserror 2.0.18", +] + [[package]] name = "codex-exec" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index dfb89a02c5..5f389f609c 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -33,6 +33,7 @@ members = [ "connectors", "config", "context-fragments", + "desktop-distribution", "shell-command", "shell-escalation", "skills", @@ -165,6 +166,7 @@ codex-home = { path = "codex-home" } codex-config = { path = "config" } codex-connectors = { path = "connectors" } codex-context-fragments = { path = "context-fragments" } +codex-desktop-distribution = { path = "desktop-distribution" } codex-core = { path = "core" } codex-core-api = { path = "core-api" } codex-core-plugins = { path = "core-plugins" } diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index 8896759f98..c63c8b020b 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -31,6 +31,7 @@ codex-chatgpt = { workspace = true } codex-cloud-tasks = { path = "../cloud-tasks" } codex-utils-cli = { workspace = true } codex-config = { workspace = true } +codex-desktop-distribution = { workspace = true } codex-core = { workspace = true } codex-core-plugins = { workspace = true } codex-home = { workspace = true } diff --git a/codex-rs/cli/src/desktop_app/mac.rs b/codex-rs/cli/src/desktop_app/mac.rs index 85928b551f..7ed7d32c42 100644 --- a/codex-rs/cli/src/desktop_app/mac.rs +++ b/codex-rs/cli/src/desktop_app/mac.rs @@ -1,4 +1,5 @@ use anyhow::Context as _; +use codex_desktop_distribution::discover_installed_distribution; use std::ffi::CString; use std::path::Path; use std::path::PathBuf; @@ -13,7 +14,7 @@ pub async fn run_mac_app_open_or_install( workspace: PathBuf, download_url_override: Option, ) -> anyhow::Result<()> { - if let Some(app_path) = find_existing_codex_app_path() { + if let Some(app_path) = find_existing_codex_app_path().await? { eprintln!( "Opening Codex Desktop at {app_path}...", app_path = app_path.display() @@ -63,18 +64,11 @@ fn is_apple_silicon_mac() -> bool { || macos_sysctl_flag("hw.optional.arm64").unwrap_or(false) } -fn find_existing_codex_app_path() -> Option { - candidate_codex_app_paths() - .into_iter() - .find(|candidate| candidate.is_dir()) -} - -fn candidate_codex_app_paths() -> Vec { - let mut paths = vec![PathBuf::from("/Applications/Codex.app")]; - if let Some(home) = std::env::var_os("HOME") { - paths.push(PathBuf::from(home).join("Applications").join("Codex.app")); - } - paths +async fn find_existing_codex_app_path() -> anyhow::Result> { + let distribution = tokio::task::spawn_blocking(discover_installed_distribution) + .await + .context("Desktop discovery task failed")??; + Ok(distribution.map(|distribution| distribution.app_root().to_path_buf())) } async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()> { diff --git a/codex-rs/cli/src/desktop_app/windows.rs b/codex-rs/cli/src/desktop_app/windows.rs index 717c54dda4..c2505ebc83 100644 --- a/codex-rs/cli/src/desktop_app/windows.rs +++ b/codex-rs/cli/src/desktop_app/windows.rs @@ -1,4 +1,5 @@ use anyhow::Context as _; +use codex_desktop_distribution::discover_installed_distribution; use std::path::Path; use std::path::PathBuf; use tokio::process::Command; @@ -31,19 +32,10 @@ pub async fn run_windows_app_open_or_install( } async fn codex_app_is_installed() -> anyhow::Result { - let output = Command::new("powershell.exe") - .arg("-NoProfile") - .arg("-Command") - .arg("Get-StartApps -Name 'Codex' | Select-Object -First 1 -ExpandProperty AppID") - .output() + Ok(tokio::task::spawn_blocking(discover_installed_distribution) .await - .context("failed to invoke `powershell.exe`")?; - - if !output.status.success() { - return Ok(false); - } - - Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty()) + .context("Desktop discovery task failed")?? + .is_some()) } async fn open_url(url: &str) -> anyhow::Result<()> { diff --git a/codex-rs/desktop-distribution/BUILD.bazel b/codex-rs/desktop-distribution/BUILD.bazel new file mode 100644 index 0000000000..a19bddd76a --- /dev/null +++ b/codex-rs/desktop-distribution/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "desktop-distribution", + crate_name = "codex_desktop_distribution", +) diff --git a/codex-rs/desktop-distribution/Cargo.toml b/codex-rs/desktop-distribution/Cargo.toml new file mode 100644 index 0000000000..9040720cdd --- /dev/null +++ b/codex-rs/desktop-distribution/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "codex-desktop-distribution" +version.workspace = true +edition.workspace = true +license.workspace = true + +[lib] +name = "codex_desktop_distribution" +path = "src/lib.rs" +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-utils-absolute-path = { workspace = true } +thiserror = { workspace = true } + +[dev-dependencies] +pretty_assertions = { workspace = true } +tempfile = { workspace = true } diff --git a/codex-rs/desktop-distribution/src/lib.rs b/codex-rs/desktop-distribution/src/lib.rs new file mode 100644 index 0000000000..dc895cf7cd --- /dev/null +++ b/codex-rs/desktop-distribution/src/lib.rs @@ -0,0 +1,187 @@ +//! Locates the trusted Codex Desktop resources bundled with the installed app. + +use std::fs; +use std::io; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; + +use codex_utils_absolute_path::AbsolutePathBuf; + +#[cfg(target_os = "macos")] +#[path = "macos.rs"] +mod platform; +#[cfg(not(any(target_os = "macos", windows)))] +#[path = "unsupported.rs"] +mod platform; +#[cfg(windows)] +#[path = "windows.rs"] +mod platform; + +// Resource override for trusted Desktop development builds and external CLI launchers. +const DESKTOP_RESOURCES_PATH_ENV_VAR: &str = "CODEX_DESKTOP_RESOURCES_PATH"; + +#[derive(Debug, thiserror::Error)] +pub enum DesktopDistributionError { + /// No launcher hint was provided and no installed Codex Desktop app was found. This can + /// happen, for example, when Desktop was uninstalled after installing a bundled plugin. + #[error("no installed Codex Desktop distribution was found")] + NotFound, + /// The platform app-discovery mechanism could not be queried successfully. + #[error("Codex Desktop discovery failed: {0}")] + Discovery(String), + /// An expected app resource could not be read or canonicalized. + #[error("Codex Desktop filesystem validation failed during {stage}: {source}")] + Filesystem { + stage: &'static str, + #[source] + source: io::Error, + }, + /// A requested resource was absolute, contained `..`, escaped through a symlink, or had the + /// wrong file kind, so it was not strictly contained beneath Desktop's resources directory. + #[error("Codex Desktop resource containment failed: {0}")] + Containment(String), +} + +#[derive(Debug)] +pub struct DesktopResources { + root: AbsolutePathBuf, +} + +impl DesktopResources { + pub fn contained_file( + &self, + relative_path: impl AsRef, + ) -> Result { + contained_path(&self.root, relative_path.as_ref(), ResourceKind::File) + } + + pub fn contained_directory( + &self, + relative_path: impl AsRef, + ) -> Result { + contained_path(&self.root, relative_path.as_ref(), ResourceKind::Directory) + } + + /// Uses a resources directory supplied by the trusted Desktop launcher. + pub fn from_trusted_path(root: PathBuf) -> Result { + let root = canonical(&root, "resources root")?; + if !root.as_path().is_dir() { + return Err(containment("expected the resources root to be a directory")); + } + Ok(Self { root }) + } +} + +#[derive(Debug)] +pub struct InstalledDesktop { + app_root: AbsolutePathBuf, + resources: DesktopResources, +} + +impl InstalledDesktop { + pub fn app_root(&self) -> &Path { + self.app_root.as_path() + } + + #[cfg(any(target_os = "macos", windows, test))] + fn from_paths( + app_root: PathBuf, + resources_root: PathBuf, + ) -> Result { + let app_root = canonical(&app_root, "application root")?; + let resources = DesktopResources::from_trusted_path(resources_root)?; + if resources.root == app_root || !resources.root.as_path().starts_with(app_root.as_path()) { + return Err(containment( + "resources root is not strictly below the discovered application root", + )); + } + Ok(Self { + app_root, + resources, + }) + } +} + +/// Uses resources supplied by Desktop, otherwise discovers the installed stable app. +pub fn locate_current_or_installed_resources() -> Result +{ + if let Some(resources_root) = std::env::var_os(DESKTOP_RESOURCES_PATH_ENV_VAR) { + return DesktopResources::from_trusted_path(PathBuf::from(resources_root)); + } + discover_installed_distribution()? + .map(|distribution| distribution.resources) + .ok_or(DesktopDistributionError::NotFound) +} + +/// Discovers an installed Desktop app without consulting the launcher resources hint. +pub fn discover_installed_distribution() +-> Result, DesktopDistributionError> { + platform::discover() +} + +#[derive(Clone, Copy)] +enum ResourceKind { + Directory, + File, +} + +/// Resolves a resource path beneath the trusted Desktop resources root. +/// +/// Rejects empty paths and non-normal components, canonicalizes the joined path to catch symlink +/// escapes, requires the result to remain strictly below `root`, and verifies that it has the +/// requested file kind. +fn contained_path( + root: &AbsolutePathBuf, + relative_path: &Path, + kind: ResourceKind, +) -> Result { + if relative_path.as_os_str().is_empty() + || relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(containment( + "resource paths must contain only normal relative components", + )); + } + let path = canonical(root.join(relative_path).as_path(), "resource path")?; + if path == *root || !path.as_path().starts_with(root.as_path()) { + return Err(containment( + "resource must remain strictly below the Desktop resources root", + )); + } + let metadata = + fs::metadata(path.as_path()).map_err(|source| DesktopDistributionError::Filesystem { + stage: "resource metadata", + source, + })?; + let expected_kind = match kind { + ResourceKind::Directory => metadata.is_dir(), + ResourceKind::File => metadata.is_file(), + }; + if !expected_kind { + return Err(containment(match kind { + ResourceKind::Directory => "expected a directory", + ResourceKind::File => "expected a regular file", + })); + } + Ok(path) +} + +fn canonical( + path: &Path, + stage: &'static str, +) -> Result { + AbsolutePathBuf::from_absolute_path_checked(path) + .and_then(|path| path.canonicalize()) + .map_err(|source| DesktopDistributionError::Filesystem { stage, source }) +} + +fn containment(message: impl Into) -> DesktopDistributionError { + DesktopDistributionError::Containment(message.into()) +} + +#[cfg(test)] +#[path = "lib_tests.rs"] +mod tests; diff --git a/codex-rs/desktop-distribution/src/lib_tests.rs b/codex-rs/desktop-distribution/src/lib_tests.rs new file mode 100644 index 0000000000..aa88a40415 --- /dev/null +++ b/codex-rs/desktop-distribution/src/lib_tests.rs @@ -0,0 +1,81 @@ +use pretty_assertions::assert_eq; +use std::fs; + +use super::DesktopDistributionError; +use super::DesktopResources; +#[cfg(unix)] +use super::InstalledDesktop; +use super::canonical; + +#[test] +fn resolves_strictly_contained_files_and_directories() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("resources"); + let directory = root.join("plugins/demo"); + fs::create_dir_all(&directory).expect("create directory"); + let file = directory.join("hook.json"); + fs::write(&file, "{}").expect("write file"); + let resources = DesktopResources::from_trusted_path(root).expect("Desktop resources"); + + assert_eq!( + resources + .contained_directory("plugins/demo") + .expect("contained directory"), + canonical(&directory, "test directory").expect("canonical directory") + ); + assert_eq!( + resources + .contained_file("plugins/demo/hook.json") + .expect("contained file"), + canonical(&file, "test file").expect("canonical file") + ); +} + +#[test] +fn rejects_non_normal_and_wrong_kind_paths() { + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("resources"); + fs::create_dir_all(root.join("plugins")).expect("create directory"); + let resources = DesktopResources::from_trusted_path(root).expect("Desktop resources"); + + for path in ["", ".", "../resources/plugins", "/tmp"] { + assert!(matches!( + resources.contained_directory(path), + Err(DesktopDistributionError::Containment(_)) + )); + } + assert!(matches!( + resources.contained_file("plugins"), + Err(DesktopDistributionError::Containment(_)) + )); +} + +#[cfg(unix)] +#[test] +fn rejects_symlink_traversal() { + use std::os::unix::fs::symlink; + + let temp = tempfile::tempdir().expect("tempdir"); + let root = temp.path().join("resources"); + let outside = temp.path().join("outside"); + fs::create_dir_all(&root).expect("create root"); + fs::create_dir_all(&outside).expect("create outside"); + fs::write(outside.join("hook.json"), "{}").expect("write file"); + symlink(&outside, root.join("plugins")).expect("create symlink"); + let resources = DesktopResources::from_trusted_path(root).expect("Desktop resources"); + + assert!(matches!( + resources.contained_file("plugins/hook.json"), + Err(DesktopDistributionError::Containment(_)) + )); + + let app_root = temp.path().join("Codex.app"); + let resources_link = app_root.join("Contents/Resources"); + fs::create_dir_all(resources_link.parent().expect("resources parent")) + .expect("create app contents"); + symlink(&outside, &resources_link).expect("create resources symlink"); + assert!(matches!( + InstalledDesktop::from_paths(app_root, resources_link), + Err(DesktopDistributionError::Containment(_)) + )); +} diff --git a/codex-rs/desktop-distribution/src/macos.rs b/codex-rs/desktop-distribution/src/macos.rs new file mode 100644 index 0000000000..b4ec715eb8 --- /dev/null +++ b/codex-rs/desktop-distribution/src/macos.rs @@ -0,0 +1,41 @@ +use std::path::PathBuf; +use std::process::Command; + +use crate::DesktopDistributionError; +use crate::InstalledDesktop; + +const BUNDLE_IDENTIFIER: &str = "com.openai.codex"; +const TEAM_IDENTIFIER: &str = "2DC432GLL2"; + +/// Asks LaunchServices for the signed stable Codex app, independent of name or location. +pub(crate) fn discover() -> Result, DesktopDistributionError> { + let script = format!(r#"POSIX path of (path to application id "{BUNDLE_IDENTIFIER}")"#); + let output = Command::new("/usr/bin/osascript") + .args(["-e", &script]) + .output() + .map_err(|error| DesktopDistributionError::Discovery(error.to_string()))?; + if !output.status.success() { + return Ok(None); + } + let app_root = PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()); + let resources_root = app_root.join("Contents/Resources"); + if app_root.extension().is_some_and(|value| value == "app") + && resources_root.is_dir() + && has_openai_signature(&app_root)? + { + return InstalledDesktop::from_paths(app_root, resources_root).map(Some); + } + Ok(None) +} + +fn has_openai_signature(app_root: &std::path::Path) -> Result { + let requirement = format!( + r#"=identifier "{BUNDLE_IDENTIFIER}" and anchor apple generic and certificate leaf[subject.OU] = "{TEAM_IDENTIFIER}""# + ); + let verification = Command::new("/usr/bin/codesign") + .args(["--verify", "--strict", "-R", &requirement]) + .arg(app_root) + .status() + .map_err(|error| DesktopDistributionError::Discovery(error.to_string()))?; + Ok(verification.success()) +} diff --git a/codex-rs/desktop-distribution/src/unsupported.rs b/codex-rs/desktop-distribution/src/unsupported.rs new file mode 100644 index 0000000000..42a0272138 --- /dev/null +++ b/codex-rs/desktop-distribution/src/unsupported.rs @@ -0,0 +1,6 @@ +use crate::DesktopDistributionError; +use crate::InstalledDesktop; + +pub(crate) fn discover() -> Result, DesktopDistributionError> { + Ok(None) +} diff --git a/codex-rs/desktop-distribution/src/windows.rs b/codex-rs/desktop-distribution/src/windows.rs new file mode 100644 index 0000000000..e6149aa18b --- /dev/null +++ b/codex-rs/desktop-distribution/src/windows.rs @@ -0,0 +1,36 @@ +use std::path::PathBuf; +use std::process::Command; + +use crate::DesktopDistributionError; +use crate::InstalledDesktop; + +const STORE_PUBLISHER_ID: &str = "2p2nqsd0c76g0"; + +/// Queries the signed stable MSIX identity and uses the package location as the app root. +pub(crate) fn discover() -> Result, DesktopDistributionError> { + let script = format!( + r#" +$location = Get-AppxPackage -Name 'OpenAI.Codex' -ErrorAction SilentlyContinue | + Where-Object {{ $_.PublisherId -eq '{STORE_PUBLISHER_ID}' }} | + Select-Object -First 1 -ExpandProperty InstallLocation +if ($location) {{ $location }} +"# + ); + let output = Command::new("powershell.exe") + .arg("-NoProfile") + .arg("-NonInteractive") + .arg("-Command") + .arg(script) + .output() + .map_err(|error| DesktopDistributionError::Discovery(error.to_string()))?; + if !output.status.success() { + return Ok(None); + } + let install_location = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if install_location.is_empty() { + return Ok(None); + } + let app_root = PathBuf::from(install_location); + let resources_root = app_root.join("app/resources"); + InstalledDesktop::from_paths(app_root, resources_root).map(Some) +}