diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml index 983a5b151..0b17acb88 100644 --- a/.github/workflows/shell-tool-mcp.yml +++ b/.github/workflows/shell-tool-mcp.yml @@ -329,6 +329,212 @@ jobs: path: artifacts/** if-no-files-found: error + zsh-linux: + name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: metadata + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + container: + image: ${{ matrix.image }} + strategy: + fail-fast: false + matrix: + include: + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + variant: ubuntu-24.04 + image: ubuntu:24.04 + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + variant: ubuntu-22.04 + image: ubuntu:22.04 + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + variant: debian-12 + image: debian:12 + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + variant: debian-11 + image: debian:11 + - runner: ubuntu-24.04 + target: x86_64-unknown-linux-musl + variant: centos-9 + image: quay.io/centos/centos:stream9 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + variant: ubuntu-24.04 + image: arm64v8/ubuntu:24.04 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + variant: ubuntu-22.04 + image: arm64v8/ubuntu:22.04 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + variant: ubuntu-20.04 + image: arm64v8/ubuntu:20.04 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + variant: debian-12 + image: arm64v8/debian:12 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + variant: debian-11 + image: arm64v8/debian:11 + - runner: ubuntu-24.04-arm + target: aarch64-unknown-linux-musl + variant: centos-9 + image: quay.io/centos/centos:stream9 + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if command -v apt-get >/dev/null 2>&1; then + apt-get update + DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext + elif command -v dnf >/dev/null 2>&1; then + dnf install -y git gcc gcc-c++ make bison autoconf gettext + elif command -v yum >/dev/null 2>&1; then + yum install -y git gcc gcc-c++ make bison autoconf gettext + else + echo "Unsupported package manager in container" + exit 1 + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched zsh + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://git.code.sf.net/p/zsh/code /tmp/zsh + cd /tmp/zsh + git fetch --depth 1 origin 77045ef899e53b9598bebc5a41db93a548a40ca6 + git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6 + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch" + ./Util/preconfig + ./configure + cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}" + mkdir -p "$dest" + cp Src/zsh "$dest/zsh" + + - name: Smoke test zsh exec wrapper + shell: bash + run: | + set -euo pipefail + tmpdir="$(mktemp -d)" + cat > "$tmpdir/exec-wrapper" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + : "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}" + printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG" + file="$1" + shift + if [[ "$#" -eq 0 ]]; then + exec "$file" + fi + arg0="$1" + shift + exec -a "$arg0" "$file" "$@" + EOF + chmod +x "$tmpdir/exec-wrapper" + + CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \ + EXEC_WRAPPER="$tmpdir/exec-wrapper" \ + /tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt" + + grep -Fx "smoke-zsh" "$tmpdir/stdout.txt" + grep -Fx "/bin/echo" "$tmpdir/wrapper.log" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + + zsh-darwin: + name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }} + needs: metadata + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + include: + - runner: macos-15-xlarge + target: aarch64-apple-darwin + variant: macos-15 + - runner: macos-14 + target: aarch64-apple-darwin + variant: macos-14 + steps: + - name: Install build prerequisites + shell: bash + run: | + set -euo pipefail + if ! command -v autoconf >/dev/null 2>&1; then + brew install autoconf + fi + + - name: Checkout repository + uses: actions/checkout@v6 + + - name: Build patched zsh + shell: bash + run: | + set -euo pipefail + git clone --depth 1 https://git.code.sf.net/p/zsh/code /tmp/zsh + cd /tmp/zsh + git fetch --depth 1 origin 77045ef899e53b9598bebc5a41db93a548a40ca6 + git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6 + git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch" + ./Util/preconfig + ./configure + cores="$(getconf _NPROCESSORS_ONLN)" + make -j"${cores}" + + dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}" + mkdir -p "$dest" + cp Src/zsh "$dest/zsh" + + - name: Smoke test zsh exec wrapper + shell: bash + run: | + set -euo pipefail + tmpdir="$(mktemp -d)" + cat > "$tmpdir/exec-wrapper" <<'EOF' + #!/usr/bin/env bash + set -euo pipefail + : "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}" + printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG" + file="$1" + shift + if [[ "$#" -eq 0 ]]; then + exec "$file" + fi + arg0="$1" + shift + exec -a "$arg0" "$file" "$@" + EOF + chmod +x "$tmpdir/exec-wrapper" + + CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \ + EXEC_WRAPPER="$tmpdir/exec-wrapper" \ + /tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt" + + grep -Fx "smoke-zsh" "$tmpdir/stdout.txt" + grep -Fx "/bin/echo" "$tmpdir/wrapper.log" + + - uses: actions/upload-artifact@v6 + with: + name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }} + path: artifacts/** + if-no-files-found: error + package: name: Package npm module needs: @@ -336,6 +542,8 @@ jobs: - rust-binaries - bash-linux - bash-darwin + - zsh-linux + - zsh-darwin runs-on: ubuntu-latest env: PACKAGE_VERSION: ${{ needs.metadata.outputs.version }} @@ -409,7 +617,8 @@ jobs: chmod +x \ "$staging"/vendor/*/codex-exec-mcp-server \ "$staging"/vendor/*/codex-execve-wrapper \ - "$staging"/vendor/*/bash/*/bash + "$staging"/vendor/*/bash/*/bash \ + "$staging"/vendor/*/zsh/*/zsh - name: Create npm tarball shell: bash diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index a12eaa19a..17d4f26f3 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -2,7 +2,7 @@ This crate contains the code for two executables: -- `codex-exec-mcp-server` is an MCP server that provides a tool named `shell` that runs a shell command inside a sandboxed instance of Bash. Every resulting `execve(2)` call made within Bash is intercepted and run via the executable defined by the `BASH_EXEC_WRAPPER` environment variable within the Bash process. In practice, `BASH_EXEC_WRAPPER` is set to `codex-execve-wrapper`. +- `codex-exec-mcp-server` is an MCP server that provides a tool named `shell` that runs a shell command inside a sandboxed shell process. Every resulting `execve(2)` call made within that shell is intercepted and run via the executable defined by the `EXEC_WRAPPER` environment variable within the shell process. In practice, `EXEC_WRAPPER` is set to `codex-execve-wrapper`. - `codex-execve-wrapper` is the executable that takes the arguments to the `execve(2)` call and "escalates" it to the MCP server via a shared file descriptor (specified by the `CODEX_ESCALATE_SOCKET` environment variable) for consideration. Based on the [Codex `.rules`](https://developers.openai.com/codex/local-config#rules-preview), the MCP server replies with one of: - `Run`: `codex-execve-wrapper` should invoke `execve(2)` on itself to run the original command within Bash - `Escalate`: forward the file descriptors of the current process to the MCP server so the command can be run faithfully outside the sandbox. Because the MCP server will have the original FDs for `stdout` and `stderr`, it can write those directly. When the process completes, the MCP server forwards the exit code to `codex-execve-wrapper` so that it exits in a consistent manner. @@ -10,7 +10,7 @@ This crate contains the code for two executables: ## Patched Bash -We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `BASH_EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually: +We carry a small patch to `execute_cmd.c` (see `patches/bash-exec-wrapper.patch`) that adds support for `EXEC_WRAPPER`. The original commit message is “add support for BASH_EXEC_WRAPPER” and the patch applies cleanly to `a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b` from https://github.com/bminor/bash. To rebuild manually: ```bash git clone https://github.com/bminor/bash diff --git a/codex-rs/exec-server/src/posix/escalate_client.rs b/codex-rs/exec-server/src/posix/escalate_client.rs index 0594cd90f..1cb0c5908 100644 --- a/codex-rs/exec-server/src/posix/escalate_client.rs +++ b/codex-rs/exec-server/src/posix/escalate_client.rs @@ -5,11 +5,12 @@ use std::os::fd::OwnedFd; use anyhow::Context as _; -use crate::posix::escalate_protocol::BASH_EXEC_WRAPPER_ENV_VAR; use crate::posix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR; +use crate::posix::escalate_protocol::EXEC_WRAPPER_ENV_VAR; use crate::posix::escalate_protocol::EscalateAction; use crate::posix::escalate_protocol::EscalateRequest; use crate::posix::escalate_protocol::EscalateResponse; +use crate::posix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR; use crate::posix::escalate_protocol::SuperExecMessage; use crate::posix::escalate_protocol::SuperExecResult; use crate::posix::socket::AsyncDatagramSocket; @@ -38,7 +39,7 @@ pub(crate) async fn run(file: String, argv: Vec) -> anyhow::Result .filter(|(k, _)| { !matches!( k.as_str(), - ESCALATE_SOCKET_ENV_VAR | BASH_EXEC_WRAPPER_ENV_VAR + ESCALATE_SOCKET_ENV_VAR | EXEC_WRAPPER_ENV_VAR | LEGACY_BASH_EXEC_WRAPPER_ENV_VAR ) }) .collect(); diff --git a/codex-rs/exec-server/src/posix/escalate_protocol.rs b/codex-rs/exec-server/src/posix/escalate_protocol.rs index e3fc27d07..7245f88fb 100644 --- a/codex-rs/exec-server/src/posix/escalate_protocol.rs +++ b/codex-rs/exec-server/src/posix/escalate_protocol.rs @@ -8,8 +8,11 @@ use serde::Serialize; /// 'exec-server escalate' reads this to find the inherited FD for the escalate socket. pub(super) const ESCALATE_SOCKET_ENV_VAR: &str = "CODEX_ESCALATE_SOCKET"; -/// The patched bash uses this to wrap exec() calls. -pub(super) const BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER"; +/// Patched shells use this to wrap exec() calls. +pub(super) const EXEC_WRAPPER_ENV_VAR: &str = "EXEC_WRAPPER"; + +/// Compatibility alias for older patched bash builds. +pub(super) const LEGACY_BASH_EXEC_WRAPPER_ENV_VAR: &str = "BASH_EXEC_WRAPPER"; /// The client sends this to the server to request an exec() call. #[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index 2b688e63d..f66e92dc8 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -15,11 +15,12 @@ use codex_core::sandboxing::SandboxPermissions; use tokio::process::Command; use tokio_util::sync::CancellationToken; -use crate::posix::escalate_protocol::BASH_EXEC_WRAPPER_ENV_VAR; use crate::posix::escalate_protocol::ESCALATE_SOCKET_ENV_VAR; +use crate::posix::escalate_protocol::EXEC_WRAPPER_ENV_VAR; use crate::posix::escalate_protocol::EscalateAction; use crate::posix::escalate_protocol::EscalateRequest; use crate::posix::escalate_protocol::EscalateResponse; +use crate::posix::escalate_protocol::LEGACY_BASH_EXEC_WRAPPER_ENV_VAR; use crate::posix::escalate_protocol::SuperExecMessage; use crate::posix::escalate_protocol::SuperExecResult; use crate::posix::escalation_policy::EscalationPolicy; @@ -63,7 +64,11 @@ impl EscalateServer { client_socket.as_raw_fd().to_string(), ); env.insert( - BASH_EXEC_WRAPPER_ENV_VAR.to_string(), + EXEC_WRAPPER_ENV_VAR.to_string(), + self.execve_wrapper.to_string_lossy().to_string(), + ); + env.insert( + LEGACY_BASH_EXEC_WRAPPER_ENV_VAR.to_string(), self.execve_wrapper.to_string_lossy().to_string(), ); diff --git a/codex-rs/exec-server/tests/common/lib.rs b/codex-rs/exec-server/tests/common/lib.rs index ce9fa0521..57fce3a15 100644 --- a/codex-rs/exec-server/tests/common/lib.rs +++ b/codex-rs/exec-server/tests/common/lib.rs @@ -33,9 +33,6 @@ pub async fn create_transport

( where P: AsRef, { - let mcp_executable = codex_utils_cargo_bin::cargo_bin("codex-exec-mcp-server")?; - let execve_wrapper = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper")?; - // `bash` is a test resource rather than a binary target, so we must use // `find_resource!` to locate it instead of `cargo_bin()`. let bash = find_resource!("../suite/bash")?; @@ -51,8 +48,24 @@ where .await?; assert!(status.success(), "dotslash fetch failed: {status:?}"); + create_transport_with_shell_path(codex_home, dotslash_cache, bash).await +} + +pub async fn create_transport_with_shell_path( + codex_home: P, + dotslash_cache: Q, + shell_path: R, +) -> anyhow::Result +where + P: AsRef, + Q: AsRef, + R: AsRef, +{ + let mcp_executable = codex_utils_cargo_bin::cargo_bin("codex-exec-mcp-server")?; + let execve_wrapper = codex_utils_cargo_bin::cargo_bin("codex-execve-wrapper")?; + let transport = TokioChildProcess::new(Command::new(&mcp_executable).configure(|cmd| { - cmd.arg("--bash").arg(bash); + cmd.arg("--bash").arg(shell_path.as_ref()); cmd.arg("--execve").arg(&execve_wrapper); cmd.env("CODEX_HOME", codex_home.as_ref()); cmd.env("DOTSLASH_CACHE", dotslash_cache.as_ref()); diff --git a/codex-rs/exec-server/tests/suite/accept_elicitation.rs b/codex-rs/exec-server/tests/suite/accept_elicitation.rs index 365a93b83..32f30963a 100644 --- a/codex-rs/exec-server/tests/suite/accept_elicitation.rs +++ b/codex-rs/exec-server/tests/suite/accept_elicitation.rs @@ -10,6 +10,7 @@ use anyhow::ensure; use codex_exec_server::ExecResult; use exec_server_test_support::InteractiveClient; use exec_server_test_support::create_transport; +use exec_server_test_support::create_transport_with_shell_path; use exec_server_test_support::notify_readable_sandbox; use exec_server_test_support::write_default_execpolicy; use maplit::hashset; @@ -54,7 +55,46 @@ prefix_rule( let dotslash_cache_temp_dir = TempDir::new()?; let dotslash_cache = dotslash_cache_temp_dir.path(); let transport = create_transport(codex_home.as_ref(), dotslash_cache).await?; + run_accept_elicitation_for_prompt_rule_with_transport(transport).await +} +/// 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. +#[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#" +# Create a rule with `decision = "prompt"` to exercise the elicitation flow. +prefix_rule( + pattern = ["git", "init"], + decision = "prompt", + match = [ + "git init ." + ], +) +"#, + codex_home.as_ref(), + ) + .await?; + let dotslash_cache_temp_dir = TempDir::new()?; + let dotslash_cache = dotslash_cache_temp_dir.path(); + 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 +} + +async fn run_accept_elicitation_for_prompt_rule_with_transport( + transport: rmcp::transport::TokioChildProcess, +) -> Result<()> { // Create an MCP client that approves expected elicitation messages. let project_root = TempDir::new()?; let project_root_path = project_root.path().canonicalize().unwrap(); diff --git a/shell-tool-mcp/README.md b/shell-tool-mcp/README.md index ccfd0bcfb..08460f002 100644 --- a/shell-tool-mcp/README.md +++ b/shell-tool-mcp/README.md @@ -99,7 +99,7 @@ The Codex harness (used by the CLI and the VS Code extension) sends such request This package wraps the `codex-exec-mcp-server` binary and its helpers so that the shell MCP can be invoked via `npx -y @openai/codex-shell-tool-mcp`. It bundles: - `codex-exec-mcp-server` and `codex-execve-wrapper` built for macOS (arm64, x64) and Linux (musl arm64, musl x64). -- A patched Bash that honors `BASH_EXEC_WRAPPER`, built for multiple glibc baselines (Ubuntu 24.04/22.04/20.04, Debian 12/11, CentOS-like 9) and macOS (15/14/13). +- A patched Bash that honors `EXEC_WRAPPER`, built for multiple glibc baselines (Ubuntu 24.04/22.04/20.04, Debian 12/11, CentOS-like 9) and macOS (15/14/13). - A launcher (`bin/mcp-server.js`) that picks the correct binaries for the current `process.platform` / `process.arch`, specifying `--execve` and `--bash` for the MCP, as appropriate. See [the README in the Codex repo](https://github.com/openai/codex/blob/main/codex-rs/exec-server/README.md) for details. diff --git a/shell-tool-mcp/patches/bash-exec-wrapper.patch b/shell-tool-mcp/patches/bash-exec-wrapper.patch index 6a7fedbb8..f4471f2c2 100644 --- a/shell-tool-mcp/patches/bash-exec-wrapper.patch +++ b/shell-tool-mcp/patches/bash-exec-wrapper.patch @@ -6,7 +6,7 @@ index 070f5119..d20ad2b9 100644 char sample[HASH_BANG_BUFSIZ]; size_t larray; -+ char* exec_wrapper = getenv("BASH_EXEC_WRAPPER"); ++ char* exec_wrapper = getenv("EXEC_WRAPPER"); + if (exec_wrapper && *exec_wrapper && !whitespace (*exec_wrapper)) + { + char *orig_command = command; diff --git a/shell-tool-mcp/patches/zsh-exec-wrapper.patch b/shell-tool-mcp/patches/zsh-exec-wrapper.patch new file mode 100644 index 000000000..99cdcadcb --- /dev/null +++ b/shell-tool-mcp/patches/zsh-exec-wrapper.patch @@ -0,0 +1,34 @@ +diff --git a/Src/exec.c b/Src/exec.c +index 27bca11..baea760 100644 +--- a/Src/exec.c ++++ b/Src/exec.c +@@ -507,7 +507,9 @@ zexecve(char *pth, char **argv, char **newenvp) + { + int eno; + static char buf[PATH_MAX * 2+1]; +- char **eep; ++ char **eep, **exec_argv; ++ char *orig_pth = pth; ++ char *exec_wrapper; + + unmetafy(pth, NULL); + for (eep = argv; *eep; eep++) +@@ -526,8 +528,17 @@ zexecve(char *pth, char **argv, char **newenvp) + + if (newenvp == NULL) + newenvp = environ; ++ exec_argv = argv; ++ if ((exec_wrapper = getenv("EXEC_WRAPPER")) && ++ *exec_wrapper && !inblank(*exec_wrapper)) { ++ exec_argv = argv - 2; ++ exec_argv[0] = exec_wrapper; ++ exec_argv[1] = orig_pth; ++ pth = exec_wrapper; ++ } + winch_unblock(); +- execve(pth, argv, newenvp); ++ execve(pth, exec_argv, newenvp); ++ pth = orig_pth; + + /* If the execve returns (which in general shouldn't happen), * + * then check for an errno equal to ENOEXEC. This errno is set *