feat: codex-shell-tool-mcp (#7005)

This adds a GitHub workflow for building a new npm module we are
experimenting with that contains an MCP server for running Bash
commands. The new workflow, `shell-tool-mcp`, is a dependency of the
general `release` workflow so that we continue to use one version number
for all artifacts across the project in one GitHub release.

`.github/workflows/shell-tool-mcp.yml` is the primary workflow
introduced by this PR, which does the following:

- builds the `codex-exec-mcp-server` and `codex-execve-wrapper`
executables for both arm64 and x64 versions of Mac and Linux (preferring
the MUSL version for Linux)
- builds Bash (dynamically linked) for a [comically] large number of
platforms (both x64 and arm64 for most) with a small patch specified by
`shell-tool-mcp/patches/bash-exec-wrapper.patch`:
  - `debian-11`
  - `debian-12`
  - `ubuntu-20.04`
  - `ubuntu-22.04`
  - `ubuntu-24.04`
  - `centos-9`
  - `macos-13` (x64 only)
  - `macos-14` (arm64 only)
  - `macos-15` (arm64 only)
- builds the TypeScript for the [new] Node module declared in the
`shell-tool-mcp/` folder, which creates `bin/mcp-server.js`
- adds all of the native binaries to `shell-tool-mcp/vendor/` folder;
`bin/mcp-server.js` does a runtime check to determine which ones to
execute
- uses `npm pack` to create the `.tgz` for the module
- if `publish: true` is set, invokes the `npm publish` call with the
`.tgz`

The justification for building Bash for so many different operating
systems is because, since it is dynamically linked, we want to increase
our confidence that the version we build is compatible with the glibc
whatever OS we end up running on. (Note this is less of a concern with
`codex-exec-mcp-server` and `codex-execve-wrapper` on Linux, as they are
statically linked.)

This PR also introduces the code for the npm module in `shell-tool-mcp/`
(the proposed module name is `@openai/codex-shell-tool-mcp`). Initially,
I intended the module to be a single file of vanilla JavaScript (like
[`codex-cli/bin/codex.js`](ab5972d447/codex-cli/bin/codex.js)),
but some of the logic seemed a bit tricky, so I decided to port it to
TypeScript and add unit tests.

`shell-tool-mcp/src/index.ts` defines the `main()` function for the
module, which performs runtime checks to determine the clang triple to
find the path to the Rust executables within the `vendor/` folder
(`resolveTargetTriple()`). It uses a combination of `readOsRelease()`
and `resolveBashPath()` to determine the correct Bash executable to run
in the environment. Ultimately, it spawns a command like the following:

```
codex-exec-mcp-server \
    --execve codex-execve-wrapper \
    --bash custom-bash "$@"
```

Note `.github/workflows/shell-tool-mcp-ci.yml` defines a fairly standard
CI job for the module (`format`/`build`/`test`).

To test this PR, I pushed this branch to my personal fork of Codex and
ran the CI job there:

https://github.com/bolinfest/codex/actions/runs/19564311320

Admittedly, the graph looks a bit wild now:

<img width="5115" height="2969" alt="Screenshot 2025-11-20 at 11 44
58 PM"
src="https://github.com/user-attachments/assets/cc5ef306-efc1-4ed7-a137-5347e394f393"
/>

But when it finished, I was able to download `codex-shell-tool-mcp-npm`
from the **Artifacts** for the workflow in an empty temp directory,
unzip the `.zip` and then the `.tgz` inside it, followed by `xattr -rc
.` to remove the quarantine bits. Then I ran:

```shell
npx @modelcontextprotocol/inspector node /private/tmp/foobar4/package/bin/mcp-server.js
```

which launched the MCP Inspector and I was able to use it as expected!
This bodes well that this should work once the package is published to
npm:

```shell
npx @modelcontextprotocol/inspector npx @openai/codex-shell-tool-mcp
```

Also, to verify the package contains what I expect:

```shell
/tmp/foobar4/package$ tree
.
├── bin
│   └── mcp-server.js
├── package.json
├── README.md
└── vendor
    ├── aarch64-apple-darwin
    │   ├── bash
    │   │   ├── macos-14
    │   │   │   └── bash
    │   │   └── macos-15
    │   │       └── bash
    │   ├── codex-exec-mcp-server
    │   └── codex-execve-wrapper
    ├── aarch64-unknown-linux-musl
    │   ├── bash
    │   │   ├── centos-9
    │   │   │   └── bash
    │   │   ├── debian-11
    │   │   │   └── bash
    │   │   ├── debian-12
    │   │   │   └── bash
    │   │   ├── ubuntu-20.04
    │   │   │   └── bash
    │   │   ├── ubuntu-22.04
    │   │   │   └── bash
    │   │   └── ubuntu-24.04
    │   │       └── bash
    │   ├── codex-exec-mcp-server
    │   └── codex-execve-wrapper
    ├── x86_64-apple-darwin
    │   ├── bash
    │   │   └── macos-13
    │   │       └── bash
    │   ├── codex-exec-mcp-server
    │   └── codex-execve-wrapper
    └── x86_64-unknown-linux-musl
        ├── bash
        │   ├── centos-9
        │   │   └── bash
        │   ├── debian-11
        │   │   └── bash
        │   ├── debian-12
        │   │   └── bash
        │   ├── ubuntu-20.04
        │   │   └── bash
        │   ├── ubuntu-22.04
        │   │   └── bash
        │   └── ubuntu-24.04
        │       └── bash
        ├── codex-exec-mcp-server
        └── codex-execve-wrapper

26 directories, 26 files
```
This commit is contained in:
Michael Bolin
2025-11-21 08:16:36 -08:00
committed by GitHub
parent bce030ddb5
commit d363a0968e
20 changed files with 1012 additions and 1 deletions

View File

@@ -371,8 +371,20 @@ jobs:
path: |
codex-rs/dist/${{ matrix.target }}/*
shell-tool-mcp:
name: shell-tool-mcp
needs: tag-check
uses: ./.github/workflows/shell-tool-mcp.yml
with:
release-tag: ${{ github.ref_name }}
# We are not ready to publish yet.
publish: false
secrets: inherit
release:
needs: build
needs:
- build
- shell-tool-mcp
name: release
runs-on: ubuntu-latest
permissions:

48
.github/workflows/shell-tool-mcp-ci.yml vendored Normal file
View File

@@ -0,0 +1,48 @@
name: shell-tool-mcp CI
on:
push:
paths:
- "shell-tool-mcp/**"
- ".github/workflows/shell-tool-mcp-ci.yml"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
pull_request:
paths:
- "shell-tool-mcp/**"
- ".github/workflows/shell-tool-mcp-ci.yml"
- "pnpm-lock.yaml"
- "pnpm-workspace.yaml"
env:
NODE_VERSION: 22
jobs:
test:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Format check
run: pnpm --filter @openai/codex-shell-tool-mcp run format
- name: Run tests
run: pnpm --filter @openai/codex-shell-tool-mcp test
- name: Build
run: pnpm --filter @openai/codex-shell-tool-mcp run build

412
.github/workflows/shell-tool-mcp.yml vendored Normal file
View File

@@ -0,0 +1,412 @@
name: shell-tool-mcp
on:
workflow_call:
inputs:
release-version:
description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v.
required: false
type: string
release-tag:
description: Tag name to use when downloading release artifacts (defaults to rust-v<version>).
required: false
type: string
publish:
description: Whether to publish to npm when the version is releasable.
required: false
default: true
type: boolean
env:
NODE_VERSION: 22
jobs:
metadata:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.compute.outputs.version }}
release_tag: ${{ steps.compute.outputs.release_tag }}
should_publish: ${{ steps.compute.outputs.should_publish }}
npm_tag: ${{ steps.compute.outputs.npm_tag }}
steps:
- name: Compute version and tags
id: compute
run: |
set -euo pipefail
version="${{ inputs.release-version }}"
release_tag="${{ inputs.release-tag }}"
if [[ -z "$version" ]]; then
if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then
version="${release_tag#rust-v}"
elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then
version="${GITHUB_REF_NAME#rust-v}"
release_tag="${GITHUB_REF_NAME}"
else
echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag."
exit 1
fi
fi
if [[ -z "$release_tag" ]]; then
release_tag="rust-v${version}"
fi
npm_tag=""
should_publish="false"
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
should_publish="true"
elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
should_publish="true"
npm_tag="alpha"
fi
echo "version=${version}" >> "$GITHUB_OUTPUT"
echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT"
echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT"
echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT"
rust-binaries:
name: Build Rust - ${{ matrix.target }}
needs: metadata
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
defaults:
run:
working-directory: codex-rs
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15-xlarge
target: aarch64-apple-darwin
- runner: macos-15-xlarge
target: x86_64-apple-darwin
- runner: ubuntu-24.04
target: x86_64-unknown-linux-musl
install_musl: true
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-musl
install_musl: true
steps:
- name: Checkout repository
uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@1.90
with:
targets: ${{ matrix.target }}
- if: ${{ matrix.install_musl }}
name: Install musl build dependencies
run: |
sudo apt-get update
sudo apt-get install -y musl-tools pkg-config
- name: Build exec server binaries
run: cargo build --release --target ${{ matrix.target }} --bin codex-exec-mcp-server --bin codex-execve-wrapper
- name: Stage exec server binaries
run: |
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}"
mkdir -p "$dest"
cp "target/${{ matrix.target }}/release/codex-exec-mcp-server" "$dest/"
cp "target/${{ matrix.target }}/release/codex-execve-wrapper" "$dest/"
- uses: actions/upload-artifact@v4
with:
name: shell-tool-mcp-rust-${{ matrix.target }}
path: artifacts/**
if-no-files-found: error
bash-linux:
name: Build Bash (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: ubuntu-20.04
image: ubuntu:20.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@v5
- name: Build patched Bash
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/bminor/bash /tmp/bash
cd /tmp/bash
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}"
mkdir -p "$dest"
cp bash "$dest/bash"
- uses: actions/upload-artifact@v4
with:
name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
bash-darwin:
name: Build Bash (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
- runner: macos-13
target: x86_64-apple-darwin
variant: macos-13
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Build patched Bash
shell: bash
run: |
set -euo pipefail
git clone --depth 1 https://github.com/bminor/bash /tmp/bash
cd /tmp/bash
git fetch --depth 1 origin a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
./configure --without-bash-malloc
cores="$(getconf _NPROCESSORS_ONLN)"
make -j"${cores}"
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}"
mkdir -p "$dest"
cp bash "$dest/bash"
- uses: actions/upload-artifact@v4
with:
name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }}
path: artifacts/**
if-no-files-found: error
package:
name: Package npm module
needs:
- metadata
- rust-binaries
- bash-linux
- bash-darwin
runs-on: ubuntu-latest
env:
PACKAGE_VERSION: ${{ needs.metadata.outputs.version }}
steps:
- name: Checkout repository
uses: actions/checkout@v5
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.8.1
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
- name: Install JavaScript dependencies
run: pnpm install --frozen-lockfile
- name: Build (shell-tool-mcp)
run: pnpm --filter @openai/codex-shell-tool-mcp run build
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Assemble staging directory
id: staging
shell: bash
run: |
set -euo pipefail
staging="${STAGING_DIR}"
mkdir -p "$staging" "$staging/vendor"
cp shell-tool-mcp/README.md "$staging/"
cp shell-tool-mcp/package.json "$staging/"
cp -R shell-tool-mcp/bin "$staging/"
found_vendor="false"
shopt -s nullglob
for vendor_dir in artifacts/*/vendor; do
rsync -av "$vendor_dir/" "$staging/vendor/"
found_vendor="true"
done
if [[ "$found_vendor" == "false" ]]; then
echo "No vendor payloads were downloaded."
exit 1
fi
node - <<'NODE'
import fs from "node:fs";
import path from "node:path";
const stagingDir = process.env.STAGING_DIR;
const version = process.env.PACKAGE_VERSION;
const pkgPath = path.join(stagingDir, "package.json");
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
pkg.version = version;
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
NODE
echo "dir=$staging" >> "$GITHUB_OUTPUT"
env:
STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp
- name: Ensure binaries are executable
run: |
set -euo pipefail
staging="${{ steps.staging.outputs.dir }}"
chmod +x \
"$staging"/vendor/*/codex-exec-mcp-server \
"$staging"/vendor/*/codex-execve-wrapper \
"$staging"/vendor/*/bash/*/bash
- name: Create npm tarball
shell: bash
run: |
set -euo pipefail
mkdir -p dist/npm
staging="${{ steps.staging.outputs.dir }}"
pack_info=$(cd "$staging" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm")
filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);')
mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz"
- uses: actions/upload-artifact@v4
with:
name: codex-shell-tool-mcp-npm
path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz
if-no-files-found: error
publish:
name: Publish npm package
needs:
- metadata
- package
if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }}
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 10.8.1
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v5
with:
node-version: ${{ env.NODE_VERSION }}
registry-url: https://registry.npmjs.org
scope: "@openai"
- name: Update npm
run: npm install -g npm@latest
- name: Download npm tarball
uses: actions/download-artifact@v4
with:
name: codex-shell-tool-mcp-npm
path: dist/npm
- name: Publish to npm
env:
NPM_TAG: ${{ needs.metadata.outputs.npm_tag }}
VERSION: ${{ needs.metadata.outputs.version }}
shell: bash
run: |
set -euo pipefail
tag_args=()
if [[ -n "${NPM_TAG}" ]]; then
tag_args+=(--tag "${NPM_TAG}")
fi
npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}"

24
pnpm-lock.yaml generated
View File

@@ -71,6 +71,30 @@ importers:
specifier: ^3.24.6
version: 3.24.6(zod@3.25.76)
shell-tool-mcp:
devDependencies:
'@types/jest':
specifier: ^29.5.14
version: 29.5.14
'@types/node':
specifier: ^20.19.18
version: 20.19.18
jest:
specifier: ^29.7.0
version: 29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2))
prettier:
specifier: ^3.6.2
version: 3.6.2
ts-jest:
specifier: ^29.3.4
version: 29.4.4(@babel/core@7.28.4)(@jest/transform@29.7.0)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.28.4))(esbuild@0.25.10)(jest-util@29.7.0)(jest@29.7.0(@types/node@20.19.18)(ts-node@10.9.2(@types/node@20.19.18)(typescript@5.9.2)))(typescript@5.9.2)
tsup:
specifier: ^8.5.0
version: 8.5.0(postcss@8.5.6)(typescript@5.9.2)(yaml@2.8.1)
typescript:
specifier: ^5.9.2
version: 5.9.2
packages:
'@babel/code-frame@7.27.1':

View File

@@ -1,6 +1,7 @@
packages:
- docs
- sdk/typescript
- shell-tool-mcp
ignoredBuiltDependencies:
- esbuild

2
shell-tool-mcp/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/bin/
/node_modules/

31
shell-tool-mcp/README.md Normal file
View File

@@ -0,0 +1,31 @@
# @openai/codex-shell-tool-mcp
This package wraps the `codex-exec-mcp-server` binary and its helpers so that the shell MCP can be invoked via `npx @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 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.
## Usage
```bash
npx @openai/codex-shell-tool-mcp --help
```
The launcher selects a Rust target triple based on the host and chooses the closest Bash variant by inspecting `/etc/os-release` on Linux or the Darwin major version on macOS.
## 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:
```bash
git clone https://github.com/bminor/bash
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
git apply /path/to/patches/bash-exec-wrapper.patch
./configure --without-bash-malloc
make -j"$(nproc)"
```
## Release workflow
`.github/workflows/shell-tool-mcp.yml` builds the Rust binaries, compiles the patched Bash variants, assembles the `vendor/` tree, and creates `codex-shell-tool-mcp-npm-<version>.tgz` for inclusion in the Rust GitHub Release. When the version is a stable or alpha tag, the workflow also publishes the tarball to npm using OIDC. The workflow is invoked from `rust-release.yml` so the package ships alongside other Codex artifacts.

View File

@@ -0,0 +1,6 @@
/** @type {import('jest').Config} */
module.exports = {
preset: "ts-jest",
testEnvironment: "node",
roots: ["<rootDir>/tests"],
};

View File

@@ -0,0 +1,40 @@
{
"name": "@openai/codex-shell-tool-mcp",
"version": "0.0.0-dev",
"description": "Codex MCP server for the shell tool with patched Bash and exec wrappers.",
"license": "Apache-2.0",
"bin": {
"codex-shell-tool-mcp": "bin/mcp-server.js"
},
"engines": {
"node": ">=18"
},
"files": [
"bin",
"vendor",
"README.md"
],
"repository": {
"type": "git",
"url": "git+https://github.com/openai/codex.git",
"directory": "shell-tool-mcp"
},
"scripts": {
"clean": "rm -rf bin",
"build": "tsup",
"build:watch": "tsup --watch",
"test": "jest",
"test:watch": "jest --watch",
"format": "prettier --check .",
"format:fix": "prettier --write ."
},
"devDependencies": {
"@types/jest": "^29.5.14",
"@types/node": "^20.19.18",
"jest": "^29.7.0",
"prettier": "^3.6.2",
"ts-jest": "^29.3.4",
"tsup": "^8.5.0",
"typescript": "^5.9.2"
}
}

View File

@@ -0,0 +1,24 @@
diff --git a/execute_cmd.c b/execute_cmd.c
index 070f5119..d20ad2b9 100644
--- a/execute_cmd.c
+++ b/execute_cmd.c
@@ -6129,6 +6129,19 @@ shell_execve (char *command, char **args, char **env)
char sample[HASH_BANG_BUFSIZ];
size_t larray;
+ char* exec_wrapper = getenv("BASH_EXEC_WRAPPER");
+ if (exec_wrapper && *exec_wrapper && !whitespace (*exec_wrapper))
+ {
+ char *orig_command = command;
+
+ larray = strvec_len (args);
+
+ memmove (args + 2, args, (++larray) * sizeof (char *));
+ args[0] = exec_wrapper;
+ args[1] = orig_command;
+ command = exec_wrapper;
+ }
+
SETOSTYPE (0); /* Some systems use for USG/POSIX semantics */
execve (command, args, env);
i = errno; /* error from execve() */

View File

@@ -0,0 +1,115 @@
import path from "node:path";
import os from "node:os";
import { DARWIN_BASH_VARIANTS, LINUX_BASH_VARIANTS } from "./constants";
import { BashSelection, OsReleaseInfo } from "./types";
function supportedDetail(variants: ReadonlyArray<{ name: string }>): string {
return `Supported variants: ${variants.map((variant) => variant.name).join(", ")}`;
}
export function selectLinuxBash(
bashRoot: string,
info: OsReleaseInfo,
): BashSelection {
const versionId = info.versionId;
const candidates: Array<{
variant: (typeof LINUX_BASH_VARIANTS)[number];
matchesVersion: boolean;
}> = [];
for (const variant of LINUX_BASH_VARIANTS) {
const matchesId =
variant.ids.includes(info.id) ||
variant.ids.some((id) => info.idLike.includes(id));
if (!matchesId) {
continue;
}
const matchesVersion = Boolean(
versionId &&
variant.versions.some((prefix) => versionId.startsWith(prefix)),
);
candidates.push({ variant, matchesVersion });
}
const pickVariant = (list: typeof candidates) =>
list.find((item) => item.variant)?.variant;
const preferred = pickVariant(
candidates.filter((item) => item.matchesVersion),
);
if (preferred) {
return {
path: path.join(bashRoot, preferred.name, "bash"),
variant: preferred.name,
};
}
const fallbackMatch = pickVariant(candidates);
if (fallbackMatch) {
return {
path: path.join(bashRoot, fallbackMatch.name, "bash"),
variant: fallbackMatch.name,
};
}
const fallback = LINUX_BASH_VARIANTS[0];
if (fallback) {
return {
path: path.join(bashRoot, fallback.name, "bash"),
variant: fallback.name,
};
}
const detail = supportedDetail(LINUX_BASH_VARIANTS);
throw new Error(
`Unable to select a Bash variant for ${info.id || "unknown"} ${versionId || ""}. ${detail}`,
);
}
export function selectDarwinBash(
bashRoot: string,
darwinRelease: string,
): BashSelection {
const darwinMajor = Number.parseInt(darwinRelease.split(".")[0] || "0", 10);
const preferred = DARWIN_BASH_VARIANTS.find(
(variant) => darwinMajor >= variant.minDarwin,
);
if (preferred) {
return {
path: path.join(bashRoot, preferred.name, "bash"),
variant: preferred.name,
};
}
const fallback = DARWIN_BASH_VARIANTS[0];
if (fallback) {
return {
path: path.join(bashRoot, fallback.name, "bash"),
variant: fallback.name,
};
}
const detail = supportedDetail(DARWIN_BASH_VARIANTS);
throw new Error(
`Unable to select a macOS Bash build (darwin ${darwinMajor}). ${detail}`,
);
}
export function resolveBashPath(
targetRoot: string,
platform: NodeJS.Platform,
darwinRelease = os.release(),
osInfo: OsReleaseInfo | null = null,
): BashSelection {
const bashRoot = path.join(targetRoot, "bash");
if (platform === "linux") {
if (!osInfo) {
throw new Error("Linux OS info is required to select a Bash variant.");
}
return selectLinuxBash(bashRoot, osInfo);
}
if (platform === "darwin") {
return selectDarwinBash(bashRoot, darwinRelease);
}
throw new Error(`Unsupported platform for Bash selection: ${platform}`);
}

View File

@@ -0,0 +1,20 @@
import { DarwinBashVariant, LinuxBashVariant } from "./types";
export const LINUX_BASH_VARIANTS: ReadonlyArray<LinuxBashVariant> = [
{ name: "ubuntu-24.04", ids: ["ubuntu"], versions: ["24.04"] },
{ name: "ubuntu-22.04", ids: ["ubuntu"], versions: ["22.04"] },
{ name: "ubuntu-20.04", ids: ["ubuntu"], versions: ["20.04"] },
{ name: "debian-12", ids: ["debian"], versions: ["12"] },
{ name: "debian-11", ids: ["debian"], versions: ["11"] },
{
name: "centos-9",
ids: ["centos", "rhel", "rocky", "almalinux"],
versions: ["9"],
},
];
export const DARWIN_BASH_VARIANTS: ReadonlyArray<DarwinBashVariant> = [
{ name: "macos-15", minDarwin: 24 },
{ name: "macos-14", minDarwin: 23 },
{ name: "macos-13", minDarwin: 22 },
];

101
shell-tool-mcp/src/index.ts Normal file
View File

@@ -0,0 +1,101 @@
// Launches the codex-exec-mcp-server binary bundled in this package.
import { spawn } from "node:child_process";
import { accessSync, constants } from "node:fs";
import os from "node:os";
import path from "node:path";
import { resolveBashPath } from "./bashSelection";
import { readOsRelease } from "./osRelease";
import { resolveTargetTriple } from "./platform";
const scriptPath = process.argv[1]
? path.resolve(process.argv[1])
: process.cwd();
const __dirname = path.dirname(scriptPath);
async function main(): Promise<void> {
const targetTriple = resolveTargetTriple(process.platform, process.arch);
const vendorRoot = path.join(__dirname, "..", "vendor");
const targetRoot = path.join(vendorRoot, targetTriple);
const execveWrapperPath = path.join(targetRoot, "codex-execve-wrapper");
const serverPath = path.join(targetRoot, "codex-exec-mcp-server");
const osInfo = process.platform === "linux" ? readOsRelease() : null;
const { path: bashPath } = resolveBashPath(
targetRoot,
process.platform,
os.release(),
osInfo,
);
[execveWrapperPath, serverPath, bashPath].forEach((checkPath) => {
try {
accessSync(checkPath, constants.F_OK);
} catch {
throw new Error(`Required binary missing: ${checkPath}`);
}
});
const args = [
"--execve",
execveWrapperPath,
"--bash",
bashPath,
...process.argv.slice(2),
];
const child = spawn(serverPath, args, {
stdio: "inherit",
});
const forwardSignal = (signal: NodeJS.Signals) => {
if (child.killed) {
return;
}
try {
child.kill(signal);
} catch {
/* ignore */
}
};
(["SIGINT", "SIGTERM", "SIGHUP"] as const).forEach((sig) => {
process.on(sig, () => forwardSignal(sig));
});
child.on("error", (err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});
const childResult = await new Promise<
| { type: "signal"; signal: NodeJS.Signals }
| { type: "code"; exitCode: number }
>((resolve) => {
child.on("exit", (code, signal) => {
if (signal) {
resolve({ type: "signal", signal });
} else {
resolve({ type: "code", exitCode: code ?? 1 });
}
});
});
if (childResult.type === "signal") {
// This environment running under `node --test` may not allow rethrowing a signal.
// Wrap in a try to avoid masking the original termination reason.
try {
process.kill(process.pid, childResult.signal);
} catch {
process.exit(1);
}
} else {
process.exit(childResult.exitCode);
}
}
void main().catch((err) => {
// eslint-disable-next-line no-console
console.error(err);
process.exit(1);
});

View File

@@ -0,0 +1,34 @@
import { readFileSync } from "node:fs";
import { OsReleaseInfo } from "./types";
export function parseOsRelease(contents: string): OsReleaseInfo {
const lines = contents.split("\n").filter(Boolean);
const info: Record<string, string> = {};
for (const line of lines) {
const [rawKey, rawValue] = line.split("=", 2);
if (!rawKey || rawValue === undefined) {
continue;
}
const key = rawKey.toLowerCase();
const value = rawValue.replace(/^"/, "").replace(/"$/, "");
info[key] = value;
}
const idLike = (info.id_like || "")
.split(/\s+/)
.map((item) => item.trim().toLowerCase())
.filter(Boolean);
return {
id: (info.id || "").toLowerCase(),
idLike,
versionId: info.version_id || "",
};
}
export function readOsRelease(pathname = "/etc/os-release"): OsReleaseInfo {
try {
const contents = readFileSync(pathname, "utf8");
return parseOsRelease(contents);
} catch {
return { id: "", idLike: [], versionId: "" };
}
}

View File

@@ -0,0 +1,21 @@
export function resolveTargetTriple(
platform: NodeJS.Platform,
arch: NodeJS.Architecture,
): string {
if (platform === "linux") {
if (arch === "x64") {
return "x86_64-unknown-linux-musl";
}
if (arch === "arm64") {
return "aarch64-unknown-linux-musl";
}
} else if (platform === "darwin") {
if (arch === "x64") {
return "x86_64-apple-darwin";
}
if (arch === "arm64") {
return "aarch64-apple-darwin";
}
}
throw new Error(`Unsupported platform: ${platform} (${arch})`);
}

View File

@@ -0,0 +1,21 @@
export type LinuxBashVariant = {
name: string;
ids: Array<string>;
versions: Array<string>;
};
export type DarwinBashVariant = {
name: string;
minDarwin: number;
};
export type OsReleaseInfo = {
id: string;
idLike: Array<string>;
versionId: string;
};
export type BashSelection = {
path: string;
variant: string;
};

View File

@@ -0,0 +1,41 @@
import { selectDarwinBash, selectLinuxBash } from "../src/bashSelection";
import { DARWIN_BASH_VARIANTS, LINUX_BASH_VARIANTS } from "../src/constants";
import { OsReleaseInfo } from "../src/types";
import path from "node:path";
describe("selectLinuxBash", () => {
const bashRoot = "/vendor/bash";
it("prefers exact version match when id is present", () => {
const info: OsReleaseInfo = {
id: "ubuntu",
idLike: ["debian"],
versionId: "24.04.1",
};
const selection = selectLinuxBash(bashRoot, info);
expect(selection.variant).toBe("ubuntu-24.04");
expect(selection.path).toBe(path.join(bashRoot, "ubuntu-24.04", "bash"));
});
it("falls back to first supported variant when no matches", () => {
const info: OsReleaseInfo = { id: "unknown", idLike: [], versionId: "1.0" };
const selection = selectLinuxBash(bashRoot, info);
expect(selection.variant).toBe(LINUX_BASH_VARIANTS[0].name);
});
});
describe("selectDarwinBash", () => {
const bashRoot = "/vendor/bash";
it("selects compatible darwin version", () => {
const darwinRelease = "24.0.0";
const selection = selectDarwinBash(bashRoot, darwinRelease);
expect(selection.variant).toBe("macos-15");
});
it("falls back to first darwin variant when release too old", () => {
const darwinRelease = "20.0.0";
const selection = selectDarwinBash(bashRoot, darwinRelease);
expect(selection.variant).toBe(DARWIN_BASH_VARIANTS[0].name);
});
});

View File

@@ -0,0 +1,30 @@
import { parseOsRelease } from "../src/osRelease";
describe("parseOsRelease", () => {
it("parses basic fields", () => {
const contents = `ID="ubuntu"
ID_LIKE="debian"
VERSION_ID=24.04
OTHER=ignored`;
const info = parseOsRelease(contents);
expect(info).toEqual({
id: "ubuntu",
idLike: ["debian"],
versionId: "24.04",
});
});
it("handles missing fields", () => {
const contents = "SOMETHING=else";
const info = parseOsRelease(contents);
expect(info).toEqual({ id: "", idLike: [], versionId: "" });
});
it("normalizes id_like entries", () => {
const contents = `ID="rhel"
ID_LIKE="CentOS Rocky"`;
const info = parseOsRelease(contents);
expect(info.idLike).toEqual(["centos", "rocky"]);
});
});

View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "CommonJS",
"moduleResolution": "Node",
"noEmit": true,
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true
},
"include": ["src", "tests"]
}

View File

@@ -0,0 +1,15 @@
import { defineConfig } from "tsup";
export default defineConfig({
entry: {
"mcp-server": "src/index.ts",
},
outDir: "bin",
format: ["cjs"],
target: "node18",
clean: true,
sourcemap: false,
banner: {
js: "#!/usr/bin/env node",
},
});