diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index c910f9544..e68e85b40 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1787,6 +1787,7 @@ dependencies = [ "codex-protocol", "codex-shell-command", "codex-utils-cargo-bin", + "core_test_support", "exec_server_test_support", "libc", "maplit", diff --git a/codex-rs/app-server/src/main.rs b/codex-rs/app-server/src/main.rs index b4c5d098e..c56c3c8f9 100644 --- a/codex-rs/app-server/src/main.rs +++ b/codex-rs/app-server/src/main.rs @@ -23,10 +23,12 @@ struct AppServerArgs { } fn main() -> anyhow::Result<()> { - if codex_core::maybe_run_zsh_exec_wrapper_mode()? { - return Ok(()); - } arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + // Run wrapper mode only after arg0 dispatch so `codex-linux-sandbox` + // invocations don't get misclassified as zsh exec-wrapper calls. + if codex_core::maybe_run_zsh_exec_wrapper_mode()? { + return Ok(()); + } let args = AppServerArgs::parse(); let managed_config_path = managed_config_path_from_debug_env(); let loader_overrides = LoaderOverrides { diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 94467b871..e989807ea 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -2,18 +2,15 @@ // // Running these tests with the patched zsh fork: // -// The suite uses `CODEX_TEST_ZSH_PATH` when set. Example: -// CODEX_TEST_ZSH_PATH="$HOME/.local/codex-zsh-77045ef/bin/zsh" \ -// cargo test -p codex-app-server turn_start_zsh_fork -- --nocapture -// -// For a single test: -// CODEX_TEST_ZSH_PATH="$HOME/.local/codex-zsh-77045ef/bin/zsh" \ -// cargo test -p codex-app-server turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2 -- --nocapture +// The suite resolves the shared test-only zsh DotSlash file at +// `exec-server/tests/suite/zsh` via DotSlash on first use, so `dotslash` and +// network access are required the first time the artifact is fetched. use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_responses_server_sequence; +use app_test_support::create_mock_responses_server_sequence_unchecked; use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::CommandExecutionApprovalDecision; @@ -38,6 +35,7 @@ use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use std::collections::BTreeMap; +use std::os::unix::fs::PermissionsExt; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -57,7 +55,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { let workspace = tmp.path().join("workspace"); std::fs::create_dir(&workspace)?; - let Some(zsh_path) = find_test_zsh_path() else { + let Some(zsh_path) = find_test_zsh_path()? else { eprintln!("skipping zsh fork test: no zsh executable found"); return Ok(()); }; @@ -82,7 +80,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { &zsh_path, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -167,7 +165,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { let workspace = tmp.path().join("workspace"); std::fs::create_dir(&workspace)?; - let Some(zsh_path) = find_test_zsh_path() else { + let Some(zsh_path) = find_test_zsh_path()? else { eprintln!("skipping zsh fork decline test: no zsh executable found"); return Ok(()); }; @@ -199,7 +197,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_decline_v2() -> Result<()> { &zsh_path, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -303,7 +301,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { let workspace = tmp.path().join("workspace"); std::fs::create_dir(&workspace)?; - let Some(zsh_path) = find_test_zsh_path() else { + let Some(zsh_path) = find_test_zsh_path()? else { eprintln!("skipping zsh fork cancel test: no zsh executable found"); return Ok(()); }; @@ -332,7 +330,7 @@ async fn turn_start_shell_zsh_fork_exec_approval_cancel_v2() -> Result<()> { &zsh_path, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -434,7 +432,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() let workspace = tmp.path().join("workspace"); std::fs::create_dir(&workspace)?; - let Some(zsh_path) = find_test_zsh_path() else { + let Some(zsh_path) = find_test_zsh_path()? else { eprintln!("skipping zsh fork subcommand decline test: no zsh executable found"); return Ok(()); }; @@ -446,6 +444,29 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() return Ok(()); } eprintln!("using zsh path for zsh-fork test: {}", zsh_path.display()); + let zsh_path_for_config = { + // App-server config accepts only a zsh path, not extra argv. Use a + // wrapper so this test can force `-df` and downgrade `-lc` to `-c` + // to avoid rc/login-shell startup noise. + let path = workspace.join("zsh-no-rc"); + std::fs::write( + &path, + format!( + r#"#!/bin/sh +if [ "$1" = "-lc" ]; then + shift + set -- -c "$@" +fi +exec "{}" -df "$@" +"#, + zsh_path.display() + ), + )?; + let mut permissions = std::fs::metadata(&path)?.permissions(); + permissions.set_mode(0o755); + std::fs::set_permissions(&path, permissions)?; + path + }; let tool_call_arguments = serde_json::to_string(&serde_json::json!({ "command": "/usr/bin/true && /usr/bin/true", @@ -461,7 +482,16 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() ), responses::ev_completed("resp-1"), ]); - let server = create_mock_responses_server_sequence(vec![response]).await; + let no_op_response = responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_completed("resp-2"), + ]); + // Linux CI has occasionally issued a second `/responses` POST after the + // subcommand-decline flow. This test is about approval/decline behavior in + // the zsh fork, not exact model request count, so allow an extra request + // and return a harmless no-op response if it arrives. + let server = + create_mock_responses_server_sequence_unchecked(vec![response, no_op_response]).await; create_config_toml( &codex_home, &server.uri(), @@ -471,10 +501,10 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() (Feature::UnifiedExec, false), (Feature::ShellSnapshot, false), ]), - &zsh_path, + &zsh_path_for_config, )?; - let mut mcp = McpProcess::new(&codex_home).await?; + let mut mcp = create_zsh_test_mcp_process(&codex_home, &workspace).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let start_id = mcp @@ -500,8 +530,16 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() }], cwd: Some(workspace.clone()), approval_policy: Some(codex_app_server_protocol::AskForApproval::OnRequest), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::ReadOnly { - access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, + sandbox_policy: Some(if cfg!(target_os = "linux") { + // The zsh exec-bridge wrapper uses a Unix socket back to the parent + // process. Linux restricted sandbox seccomp denies connect(2), so use + // full access here; this test is validating zsh approval/decline + // behavior, not Linux sandboxing. + codex_app_server_protocol::SandboxPolicy::DangerFullAccess + } else { + codex_app_server_protocol::SandboxPolicy::ReadOnly { + access: codex_app_server_protocol::ReadOnlyAccess::FullAccess, + } }), model: Some("mock-model".to_string()), effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), @@ -517,10 +555,13 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() let TurnStartResponse { turn } = to_response::(turn_resp)?; let mut approval_ids = Vec::new(); - for decision in [ + let mut saw_parent_approval = false; + let target_decisions = [ CommandExecutionApprovalDecision::Accept, CommandExecutionApprovalDecision::Cancel, - ] { + ]; + let mut target_decision_index = 0; + while target_decision_index < target_decisions.len() { let server_req = timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_request_message(), @@ -531,13 +572,40 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() panic!("expected CommandExecutionRequestApproval request"); }; assert_eq!(params.item_id, "call-zsh-fork-subcommand-decline"); - approval_ids.push( - params - .approval_id - .clone() - .expect("approval_id must be present for zsh subcommand approvals"), - ); assert_eq!(params.thread_id, thread.id); + let is_target_subcommand = params.command.as_deref() == Some("/usr/bin/true"); + if is_target_subcommand { + approval_ids.push( + params + .approval_id + .clone() + .expect("approval_id must be present for zsh subcommand approvals"), + ); + } + let decision = if is_target_subcommand { + let decision = target_decisions[target_decision_index].clone(); + target_decision_index += 1; + decision + } else { + let command = params + .command + .as_deref() + .expect("approval command should be present"); + assert!( + !saw_parent_approval, + "unexpected extra non-target approval: {command}" + ); + assert!( + command.contains("zsh-no-rc"), + "expected parent zsh wrapper approval, got: {command}" + ); + assert!( + command.contains("/usr/bin/true && /usr/bin/true"), + "expected tool command in parent approval, got: {command}" + ); + saw_parent_approval = true; + CommandExecutionApprovalDecision::Accept + }; mcp.send_response( request_id, serde_json::to_value(CommandExecutionRequestApprovalResponse { decision })?, @@ -545,6 +613,8 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() .await?; } + assert_eq!(approval_ids.len(), 2); + assert_ne!(approval_ids[0], approval_ids[1]); let parent_completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { loop { let completed_notif = mcp @@ -563,32 +633,61 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() } } }) - .await??; + .await; - let ThreadItem::CommandExecution { - id, - status, - aggregated_output, - .. - } = parent_completed_command_execution - else { - unreachable!("loop ensures we break on parent command execution item"); - }; - assert_eq!(id, "call-zsh-fork-subcommand-decline"); - assert_eq!(status, CommandExecutionStatus::Declined); - assert!( - aggregated_output.is_none() - || aggregated_output == Some("exec command rejected by user".to_string()) - ); - assert_eq!(approval_ids.len(), 2); - assert_ne!(approval_ids[0], approval_ids[1]); + match parent_completed_command_execution { + Ok(Ok(parent_completed_command_execution)) => { + let ThreadItem::CommandExecution { + id, + status, + aggregated_output, + .. + } = parent_completed_command_execution + else { + unreachable!("loop ensures we break on parent command execution item"); + }; + assert_eq!(id, "call-zsh-fork-subcommand-decline"); + assert_eq!(status, CommandExecutionStatus::Declined); + assert!( + aggregated_output.is_none() + || aggregated_output == Some("exec command rejected by user".to_string()) + ); - mcp.interrupt_turn_and_wait_for_aborted(thread.id, turn.id, DEFAULT_READ_TIMEOUT) - .await?; + mcp.interrupt_turn_and_wait_for_aborted( + thread.id.clone(), + turn.id.clone(), + DEFAULT_READ_TIMEOUT, + ) + .await?; + } + Ok(Err(error)) => return Err(error), + Err(_) => { + // Some zsh builds abort the turn immediately after the rejected + // subcommand without emitting a parent `item/completed`. + let completed_notif = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + let completed: TurnCompletedNotification = serde_json::from_value( + completed_notif + .params + .expect("turn/completed params must be present"), + )?; + assert_eq!(completed.thread_id, thread.id); + assert_eq!(completed.turn.id, turn.id); + assert_eq!(completed.turn.status, TurnStatus::Interrupted); + } + } Ok(()) } +async fn create_zsh_test_mcp_process(codex_home: &Path, zdotdir: &Path) -> Result { + let zdotdir = zdotdir.to_string_lossy().into_owned(); + McpProcess::new_with_env(codex_home, &[("ZDOTDIR", Some(zdotdir.as_str()))]).await +} + fn create_config_toml( codex_home: &Path, server_uri: &str, @@ -640,36 +739,24 @@ stream_max_retries = 0 ) } -fn find_test_zsh_path() -> Option { - if let Some(path) = std::env::var_os("CODEX_TEST_ZSH_PATH") { - let path = std::path::PathBuf::from(path); - if path.is_file() { - return Some(path); - } - panic!( - "CODEX_TEST_ZSH_PATH is set but is not a file: {}", - path.display() +fn find_test_zsh_path() -> Result> { + let repo_root = codex_utils_cargo_bin::repo_root()?; + let dotslash_zsh = repo_root.join("codex-rs/exec-server/tests/suite/zsh"); + if !dotslash_zsh.is_file() { + eprintln!( + "skipping zsh fork test: shared zsh DotSlash file not found at {}", + dotslash_zsh.display() ); + return Ok(None); } - - for candidate in ["/bin/zsh", "/usr/bin/zsh"] { - let path = Path::new(candidate); - if path.is_file() { - return Some(path.to_path_buf()); + match core_test_support::fetch_dotslash_file(&dotslash_zsh, None) { + Ok(path) => return Ok(Some(path)), + Err(error) => { + eprintln!("failed to fetch vendored zsh via dotslash: {error:#}"); } } - let shell = std::env::var_os("SHELL")?; - let shell_path = std::path::PathBuf::from(shell); - if shell_path - .file_name() - .is_some_and(|file_name| file_name == "zsh") - && shell_path.is_file() - { - return Some(shell_path); - } - - None + Ok(None) } fn supports_exec_wrapper_intercept(zsh_path: &Path) -> bool { diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index e5478f1b9..cdecb18dc 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -543,10 +543,12 @@ fn stage_str(stage: codex_core::features::Stage) -> &'static str { } fn main() -> anyhow::Result<()> { - if codex_core::maybe_run_zsh_exec_wrapper_mode()? { - return Ok(()); - } arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move { + // Run wrapper mode only after arg0 dispatch so `codex-linux-sandbox` + // invocations don't get misclassified as zsh exec-wrapper calls. + if codex_core::maybe_run_zsh_exec_wrapper_mode()? { + return Ok(()); + } cli_main(codex_linux_sandbox_exe).await?; Ok(()) }) diff --git a/codex-rs/core/src/zsh_exec_bridge/mod.rs b/codex-rs/core/src/zsh_exec_bridge/mod.rs index 2e3821acf..8094a4b78 100644 --- a/codex-rs/core/src/zsh_exec_bridge/mod.rs +++ b/codex-rs/core/src/zsh_exec_bridge/mod.rs @@ -166,6 +166,10 @@ impl ZshExecBridge { })?; let mut cmd = tokio::process::Command::new(&command[0]); + #[cfg(unix)] + if let Some(arg0) = &req.arg0 { + cmd.arg0(arg0); + } if command.len() > 1 { cmd.args(&command[1..]); } @@ -459,7 +463,6 @@ fn run_exec_wrapper_mode() -> anyhow::Result<()> { argv: argv.clone(), cwd, }; - let mut stream = StdUnixStream::connect(&socket_path) .with_context(|| format!("connect to wrapper socket at {socket_path}"))?; let encoded = serde_json::to_string(&request).context("serialize wrapper request")?; diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index a4422e458..a26cd5ae7 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -1,5 +1,7 @@ #![expect(clippy::expect_used)] +use anyhow::Context as _; +use anyhow::ensure; use codex_utils_cargo_bin::CargoBinError; use ctor::ctor; use tempfile::TempDir; @@ -99,6 +101,42 @@ pub fn test_tmp_path_buf() -> PathBuf { test_tmp_path().into_path_buf() } +/// Fetch a DotSlash resource and return the resolved executable/file path. +pub fn fetch_dotslash_file( + dotslash_file: &std::path::Path, + dotslash_cache: Option<&std::path::Path>, +) -> anyhow::Result { + let mut command = std::process::Command::new("dotslash"); + command.arg("--").arg("fetch").arg(dotslash_file); + if let Some(dotslash_cache) = dotslash_cache { + command.env("DOTSLASH_CACHE", dotslash_cache); + } + let output = command.output().with_context(|| { + format!( + "failed to run dotslash to fetch resource {}", + dotslash_file.display() + ) + })?; + ensure!( + output.status.success(), + "dotslash fetch failed for {}: {}", + dotslash_file.display(), + String::from_utf8_lossy(&output.stderr).trim() + ); + let fetched_path = String::from_utf8(output.stdout) + .context("dotslash fetch output was not utf8")? + .trim() + .to_string(); + ensure!(!fetched_path.is_empty(), "dotslash fetch output was empty"); + let fetched_path = PathBuf::from(fetched_path); + ensure!( + fetched_path.is_file(), + "dotslash returned non-file path: {}", + fetched_path.display() + ); + Ok(fetched_path) +} + /// Returns a default `Config` whose on-disk state is confined to the provided /// temporary directory. Using a per-test directory keeps tests hermetic and /// avoids clobbering a developer’s real `~/.codex`. diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 4d9d79270..9b40cd35d 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -58,6 +58,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } [dev-dependencies] +core_test_support = { workspace = true } codex-utils-cargo-bin = { workspace = true } codex-protocol = { workspace = true } exec_server_test_support = { workspace = true } diff --git a/codex-rs/exec-server/tests/suite/accept_elicitation.rs b/codex-rs/exec-server/tests/suite/accept_elicitation.rs index 32f30963a..865001ac6 100644 --- a/codex-rs/exec-server/tests/suite/accept_elicitation.rs +++ b/codex-rs/exec-server/tests/suite/accept_elicitation.rs @@ -61,15 +61,9 @@ prefix_rule( /// Verify the same prompt/escalation flow works when the server is launched /// with a patched zsh binary. /// -/// Set CODEX_TEST_ZSH_PATH to enable this test locally or in CI. +/// The suite resolves `tests/suite/zsh` via DotSlash on first use. #[tokio::test(flavor = "current_thread")] async fn accept_elicitation_for_prompt_rule_with_zsh() -> Result<()> { - let Some(zsh_path) = std::env::var_os("CODEX_TEST_ZSH_PATH") else { - eprintln!("skipping zsh test: CODEX_TEST_ZSH_PATH is not set"); - return Ok(()); - }; - let zsh_path = PathBuf::from(zsh_path); - let codex_home = TempDir::new()?; write_default_execpolicy( r#" @@ -87,6 +81,11 @@ prefix_rule( .await?; let dotslash_cache_temp_dir = TempDir::new()?; let dotslash_cache = dotslash_cache_temp_dir.path(); + let zsh_path = resolve_test_zsh_path(dotslash_cache).await?; + eprintln!( + "using zsh path for exec-server test: {}", + zsh_path.display() + ); let transport = create_transport_with_shell_path(codex_home.as_ref(), dotslash_cache, &zsh_path).await?; run_accept_elicitation_for_prompt_rule_with_transport(transport).await @@ -95,13 +94,13 @@ prefix_rule( async fn run_accept_elicitation_for_prompt_rule_with_transport( transport: rmcp::transport::TokioChildProcess, ) -> Result<()> { - // Create an MCP client that approves expected elicitation messages. + // Create an MCP client that approves the expected elicitation message. let project_root = TempDir::new()?; let project_root_path = project_root.path().canonicalize().unwrap(); let git_path = resolve_git_path(USE_LOGIN_SHELL).await?; + let git_init_command = format!("{git_path} init --quiet ."); let expected_elicitation_message = format!( - "Allow agent to run `{} init .` in `{}`?", - git_path, + "Allow agent to run `{git_path} init --quiet .` in `{}`?", project_root_path.display() ); let elicitation_requests: Arc>> = Default::default(); @@ -142,7 +141,7 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport( arguments: Some(object(json!( { "login": USE_LOGIN_SHELL, - "command": "git init .", + "command": git_init_command, "workdir": project_root_path.to_string_lossy(), } ))), @@ -157,15 +156,11 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport( let ExecResult { exit_code, output, .. } = serde_json::from_str::(&tool_call_content.text)?; - let git_init_succeeded = format!( - "Initialized empty Git repository in {}/.git/\n", - project_root_path.display() - ); - // Normally, this would be an exact match, but it might include extra output - // if `git config set advice.defaultBranchName false` has not been set. + // `git init --quiet` is expected to suppress the usual initialization + // banner, so assert on success and filesystem effects instead of output. assert!( - output.contains(&git_init_succeeded), - "expected output `{output}` to contain `{git_init_succeeded}`" + output.is_empty(), + "expected no output from `git init --quiet .`, got `{output}`" ); assert_eq!(exit_code, 0, "command should succeed"); assert_eq!(is_error, Some(false), "command should succeed"); @@ -192,6 +187,12 @@ async fn run_accept_elicitation_for_prompt_rule_with_transport( Ok(()) } +async fn resolve_test_zsh_path(dotslash_cache: &std::path::Path) -> Result { + let dotslash_zsh = codex_utils_cargo_bin::find_resource!("tests/suite/zsh")?; + core_test_support::fetch_dotslash_file(&dotslash_zsh, Some(dotslash_cache)) + .with_context(|| format!("failed to fetch test zsh from {}", dotslash_zsh.display())) +} + fn ensure_codex_cli() -> Result { let codex_cli = codex_utils_cargo_bin::cargo_bin("codex")?; diff --git a/codex-rs/exec-server/tests/suite/zsh b/codex-rs/exec-server/tests/suite/zsh new file mode 100755 index 000000000..34295b5c6 --- /dev/null +++ b/codex-rs/exec-server/tests/suite/zsh @@ -0,0 +1,72 @@ +#!/usr/bin/env dotslash + +// This is the patched zsh fork built by +// `.github/workflows/shell-tool-mcp.yml` for the shell-tool-mcp package. +// Fetching the prebuilt version via DotSlash makes it easier to write +// integration tests that exercise the zsh fork behavior in exec-server tests. +// +// TODO(mbolin): Currently, we use a .tgz artifact that includes binaries for +// multiple platforms, but we could save a bit of space by making arch-specific +// artifacts available in the GitHub releases and referencing those here. +{ + "name": "codex-zsh", + "platforms": { + // macOS 13 builds (and therefore x86_64) were dropped in + // https://github.com/openai/codex/pull/7295, so we only provide an + // Apple Silicon build for now. + "macos-aarch64": { + "size": 53771483, + "hash": "blake3", + "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", + "format": "tar.gz", + "path": "package/vendor/aarch64-apple-darwin/zsh/macos-15/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.104.0", + "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" + } + ] + }, + "linux-x86_64": { + "size": 53771483, + "hash": "blake3", + "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", + "format": "tar.gz", + "path": "package/vendor/x86_64-unknown-linux-musl/zsh/ubuntu-24.04/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.104.0", + "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" + } + ] + }, + "linux-aarch64": { + "size": 53771483, + "hash": "blake3", + "digest": "ff664f63f5e1fa62762c9aff0aafa66cf196faf9b157f98ec98f59c152fc7bd3", + "format": "tar.gz", + "path": "package/vendor/aarch64-unknown-linux-musl/zsh/ubuntu-24.04/zsh", + "providers": [ + { + "url": "https://github.com/openai/codex/releases/download/rust-v0.104.0/codex-shell-tool-mcp-npm-0.104.0.tgz" + }, + { + "type": "github-release", + "repo": "openai/codex", + "tag": "rust-v0.104.0", + "name": "codex-shell-tool-mcp-npm-0.104.0.tgz" + } + ] + }, + } +}