Use deep links for macOS codex app paths (#25485)

## Why

`codex app [PATH]` is the documented CLI entry point for opening Codex
Desktop on a workspace. Recent desktop builds can focus the app while
failing to honor paths passed as macOS document-open arguments via `open
-a Codex.app <workspace>`, which broke `codex app .` for users. See
#25333; related report: #25166.

The desktop app still supports the explicit
`codex://threads/new?path=...` route, so the CLI should use that
app-owned launch surface instead of depending on folder-open event
delivery.

## What Changed

- Build a `codex://threads/new?path=<workspace>` URL in the macOS app
launcher.
- Pass that URL to `open -a <Codex.app>` instead of passing the
workspace path as a document argument.
- Add coverage that workspace paths needing escaping round-trip through
URL query encoding.

## Verification

- `just test -p codex-cli codex_new_thread_url_encodes_workspace_path`
This commit is contained in:
Eric Traut
2026-06-01 09:17:08 -07:00
committed by GitHub
parent 12c37a6b5c
commit f94c49cf46

View File

@@ -82,10 +82,11 @@ async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()>
"Opening workspace {workspace}...",
workspace = workspace.display()
);
let url = codex_new_thread_url(workspace);
let status = Command::new("open")
.arg("-a")
.arg(app_path)
.arg(workspace)
.arg(&url)
.status()
.await
.context("failed to invoke `open`")?;
@@ -95,12 +96,20 @@ async fn open_codex_app(app_path: &Path, workspace: &Path) -> anyhow::Result<()>
}
anyhow::bail!(
"`open -a {app_path} {workspace}` exited with {status}",
"`open -a {app_path} {url}` exited with {status}",
app_path = app_path.display(),
workspace = workspace.display()
url = url
);
}
fn codex_new_thread_url(workspace: &Path) -> String {
let workspace = workspace.as_os_str().to_string_lossy();
let mut serializer = url::form_urlencoded::Serializer::new(String::new());
serializer.append_pair("path", workspace.as_ref());
let query = serializer.finish();
format!("codex://threads/new?{query}")
}
async fn download_and_install_codex_to_user_applications(dmg_url: &str) -> anyhow::Result<PathBuf> {
let temp_dir = Builder::new()
.prefix("codex-app-installer-")
@@ -293,8 +302,10 @@ fn parse_hdiutil_attach_mount_point(output: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use super::codex_new_thread_url;
use super::parse_hdiutil_attach_mount_point;
use pretty_assertions::assert_eq;
use std::path::Path;
#[test]
fn parses_mount_point_from_tab_separated_hdiutil_output() {
@@ -313,4 +324,25 @@ mod tests {
Some("/Volumes/Codex Installer")
);
}
#[test]
fn codex_new_thread_url_encodes_workspace_path() {
let url = url::Url::parse(&codex_new_thread_url(Path::new("/tmp/codex workspace/#1")))
.expect("deep link should parse");
assert_eq!(
(
url.scheme().to_string(),
url.host_str().map(str::to_string),
url.path().to_string(),
url.query_pairs().into_owned().collect::<Vec<_>>(),
),
(
"codex".to_string(),
Some("threads".to_string()),
"/new".to_string(),
vec![("path".to_string(), "/tmp/codex workspace/#1".to_string())],
)
);
}
}