diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 6f27fbf543..5819c0a226 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -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: diff --git a/.github/workflows/shell-tool-mcp-ci.yml b/.github/workflows/shell-tool-mcp-ci.yml new file mode 100644 index 0000000000..8354b127f9 --- /dev/null +++ b/.github/workflows/shell-tool-mcp-ci.yml @@ -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 diff --git a/.github/workflows/shell-tool-mcp.yml b/.github/workflows/shell-tool-mcp.yml new file mode 100644 index 0000000000..d12f57476d --- /dev/null +++ b/.github/workflows/shell-tool-mcp.yml @@ -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). + 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[@]}" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d944a66fc..03d88bce45 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index fef2aa1911..ddd5674bf1 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -1,6 +1,7 @@ packages: - docs - sdk/typescript + - shell-tool-mcp ignoredBuiltDependencies: - esbuild diff --git a/shell-tool-mcp/.gitignore b/shell-tool-mcp/.gitignore new file mode 100644 index 0000000000..c6958891dd --- /dev/null +++ b/shell-tool-mcp/.gitignore @@ -0,0 +1,2 @@ +/bin/ +/node_modules/ diff --git a/shell-tool-mcp/README.md b/shell-tool-mcp/README.md new file mode 100644 index 0000000000..ba9d65a690 --- /dev/null +++ b/shell-tool-mcp/README.md @@ -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-.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. diff --git a/shell-tool-mcp/jest.config.cjs b/shell-tool-mcp/jest.config.cjs new file mode 100644 index 0000000000..8ade9496e7 --- /dev/null +++ b/shell-tool-mcp/jest.config.cjs @@ -0,0 +1,6 @@ +/** @type {import('jest').Config} */ +module.exports = { + preset: "ts-jest", + testEnvironment: "node", + roots: ["/tests"], +}; diff --git a/shell-tool-mcp/package.json b/shell-tool-mcp/package.json new file mode 100644 index 0000000000..d27c0a0c59 --- /dev/null +++ b/shell-tool-mcp/package.json @@ -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" + } +} diff --git a/shell-tool-mcp/patches/bash-exec-wrapper.patch b/shell-tool-mcp/patches/bash-exec-wrapper.patch new file mode 100644 index 0000000000..6a7fedbb8f --- /dev/null +++ b/shell-tool-mcp/patches/bash-exec-wrapper.patch @@ -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() */ diff --git a/shell-tool-mcp/src/bashSelection.ts b/shell-tool-mcp/src/bashSelection.ts new file mode 100644 index 0000000000..7da137f6a7 --- /dev/null +++ b/shell-tool-mcp/src/bashSelection.ts @@ -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}`); +} diff --git a/shell-tool-mcp/src/constants.ts b/shell-tool-mcp/src/constants.ts new file mode 100644 index 0000000000..e2bbcbf5b2 --- /dev/null +++ b/shell-tool-mcp/src/constants.ts @@ -0,0 +1,20 @@ +import { DarwinBashVariant, LinuxBashVariant } from "./types"; + +export const LINUX_BASH_VARIANTS: ReadonlyArray = [ + { 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 = [ + { name: "macos-15", minDarwin: 24 }, + { name: "macos-14", minDarwin: 23 }, + { name: "macos-13", minDarwin: 22 }, +]; diff --git a/shell-tool-mcp/src/index.ts b/shell-tool-mcp/src/index.ts new file mode 100644 index 0000000000..2ce58462f4 --- /dev/null +++ b/shell-tool-mcp/src/index.ts @@ -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 { + 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); +}); diff --git a/shell-tool-mcp/src/osRelease.ts b/shell-tool-mcp/src/osRelease.ts new file mode 100644 index 0000000000..3e55bfb1da --- /dev/null +++ b/shell-tool-mcp/src/osRelease.ts @@ -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 = {}; + 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: "" }; + } +} diff --git a/shell-tool-mcp/src/platform.ts b/shell-tool-mcp/src/platform.ts new file mode 100644 index 0000000000..177ba52b22 --- /dev/null +++ b/shell-tool-mcp/src/platform.ts @@ -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})`); +} diff --git a/shell-tool-mcp/src/types.ts b/shell-tool-mcp/src/types.ts new file mode 100644 index 0000000000..28748101c2 --- /dev/null +++ b/shell-tool-mcp/src/types.ts @@ -0,0 +1,21 @@ +export type LinuxBashVariant = { + name: string; + ids: Array; + versions: Array; +}; + +export type DarwinBashVariant = { + name: string; + minDarwin: number; +}; + +export type OsReleaseInfo = { + id: string; + idLike: Array; + versionId: string; +}; + +export type BashSelection = { + path: string; + variant: string; +}; diff --git a/shell-tool-mcp/tests/bashSelection.test.ts b/shell-tool-mcp/tests/bashSelection.test.ts new file mode 100644 index 0000000000..90753e675b --- /dev/null +++ b/shell-tool-mcp/tests/bashSelection.test.ts @@ -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); + }); +}); diff --git a/shell-tool-mcp/tests/osRelease.test.ts b/shell-tool-mcp/tests/osRelease.test.ts new file mode 100644 index 0000000000..2291129868 --- /dev/null +++ b/shell-tool-mcp/tests/osRelease.test.ts @@ -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"]); + }); +}); diff --git a/shell-tool-mcp/tsconfig.json b/shell-tool-mcp/tsconfig.json new file mode 100644 index 0000000000..90bb3e0d78 --- /dev/null +++ b/shell-tool-mcp/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "CommonJS", + "moduleResolution": "Node", + "noEmit": true, + "strict": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "skipLibCheck": true + }, + "include": ["src", "tests"] +} diff --git a/shell-tool-mcp/tsup.config.ts b/shell-tool-mcp/tsup.config.ts new file mode 100644 index 0000000000..8e4e2d4e38 --- /dev/null +++ b/shell-tool-mcp/tsup.config.ts @@ -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", + }, +});