diff --git a/.github/workflows/issue-deduplicator.yml b/.github/workflows/issue-deduplicator.yml index 579b6a3685..5c5463e2c7 100644 --- a/.github/workflows/issue-deduplicator.yml +++ b/.github/workflows/issue-deduplicator.yml @@ -46,7 +46,6 @@ jobs: with: openai-api-key: ${{ secrets.CODEX_OPENAI_API_KEY }} allow-users: "*" - model: gpt-5.1 prompt: | You are an assistant that triages new GitHub issues by identifying potential duplicates. diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index 6f27fbf543..5c814ac8f7 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: @@ -395,6 +407,14 @@ jobs: - name: List run: ls -R dist/ + # This is a temporary fix: we should modify shell-tool-mcp.yml so these + # files do not end up in dist/ in the first place. + - name: Delete entries from dist/ that should not go in the release + run: | + rm -rf dist/shell-tool-mcp* + + ls -R dist/ + - name: Define release name id: release_name run: | 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/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index fea573f8ac..c891e76fa7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -187,8 +187,10 @@ dependencies = [ "codex-app-server-protocol", "codex-core", "codex-protocol", + "core_test_support", "serde", "serde_json", + "shlex", "tokio", "uuid", "wiremock", @@ -860,7 +862,6 @@ dependencies = [ "codex-login", "codex-protocol", "codex-utils-json-to-toml", - "codex-windows-sandbox", "core_test_support", "mcp-types", "opentelemetry-appender-tracing", @@ -869,6 +870,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "shlex", "tempfile", "tokio", "toml", @@ -1216,6 +1218,7 @@ dependencies = [ "socket2 0.6.0", "tempfile", "tokio", + "tokio-util", "tracing", "tracing-subscriber", ] @@ -1776,6 +1779,7 @@ dependencies = [ "notify", "regex-lite", "serde_json", + "shlex", "tempfile", "tokio", "walkdir", @@ -3737,11 +3741,13 @@ dependencies = [ "assert_cmd", "codex-core", "codex-mcp-server", + "core_test_support", "mcp-types", "os_info", "pretty_assertions", "serde", "serde_json", + "shlex", "tokio", "wiremock", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index b34cd46b4b..226a91beff 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -169,7 +169,6 @@ reqwest = "0.12" rmcp = { version = "0.8.5", default-features = false } schemars = "0.8.22" seccompiler = "0.5.0" -sentry = "0.34.0" serde = "1" serde_json = "1" serde_with = "3.14" diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 6ecf0f9256..13b7b8888c 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -15,6 +15,7 @@ use codex_protocol::protocol::CodexErrorInfo as CoreCodexErrorInfo; use codex_protocol::protocol::CreditsSnapshot as CoreCreditsSnapshot; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow; +use codex_protocol::protocol::SessionSource as CoreSessionSource; use codex_protocol::user_input::UserInput as CoreUserInput; use mcp_types::ContentBlock as McpContentBlock; use schemars::JsonSchema; @@ -50,6 +51,9 @@ macro_rules! v2_enum_from_core { } /// This translation layer make sure that we expose codex error code in camel case. +/// +/// When an upstream HTTP status is available (for example, from the Responses API or a provider), +/// it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -256,6 +260,56 @@ pub enum CommandAction { }, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(rename_all = "camelCase", export_to = "v2/")] +#[derive(Default)] +pub enum SessionSource { + Cli, + #[serde(rename = "vscode")] + #[ts(rename = "vscode")] + #[default] + VsCode, + Exec, + AppServer, + #[serde(other)] + Unknown, +} + +impl From for SessionSource { + fn from(value: CoreSessionSource) -> Self { + match value { + CoreSessionSource::Cli => SessionSource::Cli, + CoreSessionSource::VSCode => SessionSource::VsCode, + CoreSessionSource::Exec => SessionSource::Exec, + CoreSessionSource::Mcp => SessionSource::AppServer, + CoreSessionSource::SubAgent(_) => SessionSource::Unknown, + CoreSessionSource::Unknown => SessionSource::Unknown, + } + } +} + +impl From for CoreSessionSource { + fn from(value: SessionSource) -> Self { + match value { + SessionSource::Cli => CoreSessionSource::Cli, + SessionSource::VsCode => CoreSessionSource::VSCode, + SessionSource::Exec => CoreSessionSource::Exec, + SessionSource::AppServer => CoreSessionSource::Mcp, + SessionSource::Unknown => CoreSessionSource::Unknown, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct GitInfo { + pub sha: Option, + pub branch: Option, + pub origin_url: Option, +} + impl CommandAction { pub fn into_core(self) -> CoreParsedCommand { match self { @@ -578,11 +632,20 @@ pub struct Thread { pub id: String, /// Usually the first user message in the thread, if available. pub preview: String, + /// Model provider used for this thread (for example, 'openai'). pub model_provider: String, /// Unix timestamp (in seconds) when the thread was created. pub created_at: i64, /// [UNSTABLE] Path to the thread on disk. pub path: PathBuf, + /// Working directory captured for the thread. + pub cwd: PathBuf, + /// Version of the CLI that created the thread. + pub cli_version: String, + /// Origin of the thread (CLI, VSCode, codex exec, codex app-server, etc.). + pub source: SessionSource, + /// Optional Git metadata captured when the thread was created. + pub git_info: Option, /// Only populated on a `thread/resume` response. /// For all other responses and notifications returning a Thread, /// the turns field will be an empty list. @@ -615,7 +678,7 @@ pub struct Turn { #[error("{message}")] pub struct TurnError { pub message: String, - pub codex_error_code: Option, + pub codex_error_info: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -856,6 +919,7 @@ pub enum CommandExecutionStatus { InProgress, Completed, Failed, + Declined, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] @@ -1253,7 +1317,7 @@ mod tests { } #[test] - fn codex_error_code_serializes_http_status_code_in_camel_case() { + fn codex_error_info_serializes_http_status_code_in_camel_case() { let value = CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code: Some(401), }; diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 84eb68c66d..0e7849b168 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -40,7 +40,6 @@ tracing = { workspace = true, features = ["log"] } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } opentelemetry-appender-tracing = { workspace = true } uuid = { workspace = true, features = ["serde", "v7"] } -codex-windows-sandbox.workspace = true [dev-dependencies] app_test_support = { workspace = true } @@ -54,3 +53,4 @@ serial_test = { workspace = true } tempfile = { workspace = true } toml = { workspace = true } wiremock = { workspace = true } +shlex = { workspace = true } diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 35653099aa..d5ac8a57d0 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -339,6 +339,29 @@ Event notifications are the server-initiated event stream for thread lifecycles, The app-server streams JSON-RPC notifications while a turn is running. Each turn starts with `turn/started` (initial `turn`) and ends with `turn/completed` (final `turn` plus token `usage`), and clients subscribe to the events they care about, rendering each item incrementally as updates arrive. The per-item lifecycle is always: `item/started` → zero or more item-specific deltas → `item/completed`. +- `turn/started` — `{ turn }` with the turn id, empty `items`, and `status: "inProgress"`. +- `turn/completed` — `{ turn }` where `turn.status` is `completed`, `interrupted`, or `failed`; failures carry `{ error: { message, codexErrorInfo? } }`. + +Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. + +#### Errors +`error` event is emitted whenever the server hits an error mid-turn (for example, upstream model errors or quota limits). Carries the same `{ error: { message, codexErrorInfo? } }` payload as `turn.status: "failed"` and may precede that terminal notification. + + `codexErrorInfo` maps to the `CodexErrorInfo` enum. Common values: + - `ContextWindowExceeded` + - `UsageLimitExceeded` + - `HttpConnectionFailed { httpStatusCode? }`: upstream HTTP failures including 4xx/5xx + - `ResponseStreamConnectionFailed { httpStatusCode? }`: failure to connect to the response SSE stream + - `ResponseStreamDisconnected { httpStatusCode? }`: disconnect of the response SSE stream in the middle of a turn before completion + - `ResponseTooManyFailedAttempts { httpStatusCode? }` + - `BadRequest` + - `Unauthorized` + - `SandboxError` + - `InternalServerError` + - `Other`: all unclassified errors + +When an upstream HTTP status is available (for example, from the Responses API or a provider), it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. + #### Thread items `ThreadItem` is the tagged union carried in turn responses and `item/*` notifications. Currently we support events for the following items: diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 69346d2e31..0c2445d854 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -175,12 +175,20 @@ pub(crate) async fn apply_bespoke_event_handling( }); } ApiVersion::V2 => { + let item_id = call_id.clone(); + let command_actions = parsed_cmd + .iter() + .cloned() + .map(V2ParsedCommand::from) + .collect::>(); + let command_string = shlex_join(&command); + let params = CommandExecutionRequestApprovalParams { thread_id: conversation_id.to_string(), turn_id: turn_id.clone(), // Until we migrate the core to be aware of a first class CommandExecutionItem // and emit the corresponding EventMsg, we repurpose the call_id as the item_id. - item_id: call_id.clone(), + item_id: item_id.clone(), reason, risk: risk.map(V2SandboxCommandAssessment::from), }; @@ -190,8 +198,17 @@ pub(crate) async fn apply_bespoke_event_handling( )) .await; tokio::spawn(async move { - on_command_execution_request_approval_response(event_id, rx, conversation) - .await; + on_command_execution_request_approval_response( + event_id, + item_id, + command_string, + cwd, + command_actions, + rx, + conversation, + outgoing, + ) + .await; }); } }, @@ -264,7 +281,7 @@ pub(crate) async fn apply_bespoke_event_handling( EventMsg::Error(ev) => { let turn_error = TurnError { message: ev.message, - codex_error_code: ev.codex_error_code.map(V2CodexErrorInfo::from), + codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from), }; handle_error(conversation_id, turn_error.clone(), &turn_summary_store).await; outgoing @@ -278,7 +295,7 @@ pub(crate) async fn apply_bespoke_event_handling( // but we notify the client. let turn_error = TurnError { message: ev.message, - codex_error_code: ev.codex_error_code.map(V2CodexErrorInfo::from), + codex_error_info: ev.codex_error_info.map(V2CodexErrorInfo::from), }; outgoing .send_server_notification(ServerNotification::Error(ErrorNotification { @@ -370,16 +387,21 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } EventMsg::ExecCommandBegin(exec_command_begin_event) => { + let item_id = exec_command_begin_event.call_id.clone(); + let command_actions = exec_command_begin_event + .parsed_cmd + .into_iter() + .map(V2ParsedCommand::from) + .collect::>(); + let command = shlex_join(&exec_command_begin_event.command); + let cwd = exec_command_begin_event.cwd; + let item = ThreadItem::CommandExecution { - id: exec_command_begin_event.call_id.clone(), - command: shlex_join(&exec_command_begin_event.command), - cwd: exec_command_begin_event.cwd, + id: item_id, + command, + cwd, status: CommandExecutionStatus::InProgress, - command_actions: exec_command_begin_event - .parsed_cmd - .into_iter() - .map(V2ParsedCommand::from) - .collect(), + command_actions, aggregated_output: None, exit_code: None, duration_ms: None, @@ -417,6 +439,10 @@ pub(crate) async fn apply_bespoke_event_handling( } else { CommandExecutionStatus::Failed }; + let command_actions = parsed_cmd + .into_iter() + .map(V2ParsedCommand::from) + .collect::>(); let aggregated_output = if aggregated_output.is_empty() { None @@ -431,7 +457,7 @@ pub(crate) async fn apply_bespoke_event_handling( command: shlex_join(&command), cwd, status, - command_actions: parsed_cmd.into_iter().map(V2ParsedCommand::from).collect(), + command_actions, aggregated_output, exit_code: Some(exit_code), duration_ms: Some(duration_ms), @@ -516,6 +542,30 @@ async fn complete_file_change_item( .await; } +async fn complete_command_execution_item( + item_id: String, + command: String, + cwd: PathBuf, + command_actions: Vec, + status: CommandExecutionStatus, + outgoing: &OutgoingMessageSender, +) { + let item = ThreadItem::CommandExecution { + id: item_id, + command, + cwd, + status, + command_actions, + aggregated_output: None, + exit_code: None, + duration_ms: None, + }; + let notification = ItemCompletedNotification { item }; + outgoing + .send_server_notification(ServerNotification::ItemCompleted(notification)) + .await; +} + async fn find_and_remove_turn_summary( conversation_id: ConversationId, turn_summary_store: &TurnSummaryStore, @@ -765,42 +815,68 @@ async fn on_file_change_request_approval_response( } } +#[allow(clippy::too_many_arguments)] async fn on_command_execution_request_approval_response( event_id: String, + item_id: String, + command: String, + cwd: PathBuf, + command_actions: Vec, receiver: oneshot::Receiver, conversation: Arc, + outgoing: Arc, ) { let response = receiver.await; - let value = match response { - Ok(value) => value, + let (decision, completion_status) = match response { + Ok(value) => { + let response = serde_json::from_value::(value) + .unwrap_or_else(|err| { + error!("failed to deserialize CommandExecutionRequestApprovalResponse: {err}"); + CommandExecutionRequestApprovalResponse { + decision: ApprovalDecision::Decline, + accept_settings: None, + } + }); + + let CommandExecutionRequestApprovalResponse { + decision, + accept_settings, + } = response; + + let (decision, completion_status) = match (decision, accept_settings) { + (ApprovalDecision::Accept, Some(settings)) if settings.for_session => { + (ReviewDecision::ApprovedForSession, None) + } + (ApprovalDecision::Accept, _) => (ReviewDecision::Approved, None), + (ApprovalDecision::Decline, _) => ( + ReviewDecision::Denied, + Some(CommandExecutionStatus::Declined), + ), + (ApprovalDecision::Cancel, _) => ( + ReviewDecision::Abort, + Some(CommandExecutionStatus::Declined), + ), + }; + (decision, completion_status) + } Err(err) => { error!("request failed: {err:?}"); - return; + (ReviewDecision::Denied, Some(CommandExecutionStatus::Failed)) } }; - let response = serde_json::from_value::(value) - .unwrap_or_else(|err| { - error!("failed to deserialize CommandExecutionRequestApprovalResponse: {err}"); - CommandExecutionRequestApprovalResponse { - decision: ApprovalDecision::Decline, - accept_settings: None, - } - }); + if let Some(status) = completion_status { + complete_command_execution_item( + item_id.clone(), + command.clone(), + cwd.clone(), + command_actions.clone(), + status, + outgoing.as_ref(), + ) + .await; + } - let CommandExecutionRequestApprovalResponse { - decision, - accept_settings, - } = response; - - let decision = match (decision, accept_settings) { - (ApprovalDecision::Accept, Some(settings)) if settings.for_session => { - ReviewDecision::ApprovedForSession - } - (ApprovalDecision::Accept, _) => ReviewDecision::Approved, - (ApprovalDecision::Decline, _) => ReviewDecision::Denied, - (ApprovalDecision::Cancel, _) => ReviewDecision::Abort, - }; if let Err(err) = conversation .submit(Op::ExecApproval { id: event_id, @@ -899,7 +975,7 @@ mod tests { conversation_id, TurnError { message: "boom".to_string(), - codex_error_code: Some(V2CodexErrorInfo::InternalServerError), + codex_error_info: Some(V2CodexErrorInfo::InternalServerError), }, &turn_summary_store, ) @@ -910,7 +986,7 @@ mod tests { turn_summary.last_error, Some(TurnError { message: "boom".to_string(), - codex_error_code: Some(V2CodexErrorInfo::InternalServerError), + codex_error_info: Some(V2CodexErrorInfo::InternalServerError), }) ); Ok(()) @@ -956,7 +1032,7 @@ mod tests { conversation_id, TurnError { message: "oops".to_string(), - codex_error_code: None, + codex_error_info: None, }, &turn_summary_store, ) @@ -996,7 +1072,7 @@ mod tests { conversation_id, TurnError { message: "bad".to_string(), - codex_error_code: Some(V2CodexErrorInfo::Other), + codex_error_info: Some(V2CodexErrorInfo::Other), }, &turn_summary_store, ) @@ -1024,7 +1100,7 @@ mod tests { TurnStatus::Failed { error: TurnError { message: "bad".to_string(), - codex_error_code: Some(V2CodexErrorInfo::Other), + codex_error_info: Some(V2CodexErrorInfo::Other), } } ); @@ -1079,7 +1155,7 @@ mod tests { conversation_a, TurnError { message: "a1".to_string(), - codex_error_code: Some(V2CodexErrorInfo::BadRequest), + codex_error_info: Some(V2CodexErrorInfo::BadRequest), }, &turn_summary_store, ) @@ -1098,7 +1174,7 @@ mod tests { conversation_b, TurnError { message: "b1".to_string(), - codex_error_code: None, + codex_error_info: None, }, &turn_summary_store, ) @@ -1134,7 +1210,7 @@ mod tests { TurnStatus::Failed { error: TurnError { message: "a1".to_string(), - codex_error_code: Some(V2CodexErrorInfo::BadRequest), + codex_error_info: Some(V2CodexErrorInfo::BadRequest), } } ); @@ -1155,7 +1231,7 @@ mod tests { TurnStatus::Failed { error: TurnError { message: "b1".to_string(), - codex_error_code: None, + codex_error_info: None, } } ); diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index aaf92fe4c1..348f558ee4 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -39,6 +39,7 @@ use codex_app_server_protocol::GetConversationSummaryResponse; use codex_app_server_protocol::GetUserAgentResponse; use codex_app_server_protocol::GetUserSavedConfigResponse; use codex_app_server_protocol::GitDiffToRemoteResponse; +use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::InputItem as WireInputItem; use codex_app_server_protocol::InterruptConversationParams; use codex_app_server_protocol::JSONRPCErrorError; @@ -92,7 +93,6 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInfoResponse; use codex_app_server_protocol::UserInput as V2UserInput; use codex_app_server_protocol::UserSavedConfig; -use codex_app_server_protocol::WindowsWorldWritableWarningNotification; use codex_app_server_protocol::build_turns_from_event_msgs; use codex_backend_client::Client as BackendClient; use codex_core::AuthManager; @@ -131,7 +131,7 @@ use codex_protocol::ConversationId; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::items::TurnItem; use codex_protocol::models::ResponseItem; -use codex_protocol::protocol::GitInfo; +use codex_protocol::protocol::GitInfo as CoreGitInfo; use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot; use codex_protocol::protocol::RolloutItem; use codex_protocol::protocol::SessionMetaLine; @@ -1170,7 +1170,7 @@ impl CodexMessageProcessor { let exec_params = ExecParams { command: params.command, cwd, - timeout_ms, + expiration: timeout_ms.into(), env, with_escalated_permissions: None, justification: None, @@ -1273,10 +1273,6 @@ impl CodexMessageProcessor { return; } }; - if cfg!(windows) && config.features.enabled(Feature::WindowsSandbox) { - self.handle_windows_world_writable_warning(config.cwd.clone()) - .await; - } match self.conversation_manager.new_conversation(config).await { Ok(conversation_id) => { @@ -1996,10 +1992,6 @@ impl CodexMessageProcessor { return; } }; - if cfg!(windows) && config.features.enabled(Feature::WindowsSandbox) { - self.handle_windows_world_writable_warning(config.cwd.clone()) - .await; - } let conversation_history = if let Some(path) = path { match RolloutRecorder::get_rollout_history(&path).await { @@ -2858,53 +2850,6 @@ impl CodexMessageProcessor { Err(_) => None, } } - - /// On Windows, when using the experimental sandbox, we need to warn the user about world-writable directories. - async fn handle_windows_world_writable_warning(&self, cwd: PathBuf) { - if !cfg!(windows) { - return; - } - - if !self.config.features.enabled(Feature::WindowsSandbox) { - return; - } - - if !matches!( - self.config.sandbox_policy, - codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } - | codex_protocol::protocol::SandboxPolicy::ReadOnly - ) { - return; - } - - if self - .config - .notices - .hide_world_writable_warning - .unwrap_or(false) - { - return; - } - - // This function is stubbed out to return None on non-Windows platforms - if let Some((sample_paths, extra_count, failed_scan)) = - codex_windows_sandbox::world_writable_warning_details( - self.config.codex_home.as_path(), - cwd, - ) - { - tracing::warn!("world writable warning: {sample_paths:?} {extra_count} {failed_scan}"); - self.outgoing - .send_server_notification(ServerNotification::WindowsWorldWritableWarning( - WindowsWorldWritableWarningNotification { - sample_paths, - extra_count, - failed_scan, - }, - )) - .await; - } - } } async fn derive_config_from_params( @@ -2983,7 +2928,7 @@ fn extract_conversation_summary( path: PathBuf, head: &[serde_json::Value], session_meta: &SessionMeta, - git: Option<&GitInfo>, + git: Option<&CoreGitInfo>, fallback_provider: &str, ) -> Option { let preview = head @@ -3024,7 +2969,7 @@ fn extract_conversation_summary( }) } -fn map_git_info(git_info: &GitInfo) -> ConversationGitInfo { +fn map_git_info(git_info: &CoreGitInfo) -> ConversationGitInfo { ConversationGitInfo { sha: git_info.commit_hash.clone(), branch: git_info.branch.clone(), @@ -3047,10 +2992,18 @@ fn summary_to_thread(summary: ConversationSummary) -> Thread { preview, timestamp, model_provider, - .. + cwd, + cli_version, + source, + git_info, } = summary; let created_at = parse_datetime(timestamp.as_deref()); + let git_info = git_info.map(|info| ApiGitInfo { + sha: info.sha, + branch: info.branch, + origin_url: info.origin_url, + }); Thread { id: conversation_id.to_string(), @@ -3058,6 +3011,10 @@ fn summary_to_thread(summary: ConversationSummary) -> Thread { model_provider, created_at: created_at.map(|dt| dt.timestamp()).unwrap_or(0), path, + cwd, + cli_version, + source: source.into(), + git_info, turns: Vec::new(), } } diff --git a/codex-rs/app-server/tests/common/Cargo.toml b/codex-rs/app-server/tests/common/Cargo.toml index 6240f755ee..f8e6529d46 100644 --- a/codex-rs/app-server/tests/common/Cargo.toml +++ b/codex-rs/app-server/tests/common/Cargo.toml @@ -24,3 +24,5 @@ tokio = { workspace = true, features = [ ] } uuid = { workspace = true } wiremock = { workspace = true } +core_test_support = { path = "../../../core/tests/common" } +shlex = { workspace = true } diff --git a/codex-rs/app-server/tests/common/lib.rs b/codex-rs/app-server/tests/common/lib.rs index dc3d24ccae..7d3eb53886 100644 --- a/codex-rs/app-server/tests/common/lib.rs +++ b/codex-rs/app-server/tests/common/lib.rs @@ -9,12 +9,14 @@ pub use auth_fixtures::ChatGptIdTokenClaims; pub use auth_fixtures::encode_id_token; pub use auth_fixtures::write_chatgpt_auth; use codex_app_server_protocol::JSONRPCResponse; +pub use core_test_support::format_with_current_shell; +pub use core_test_support::format_with_current_shell_display; pub use mcp_process::McpProcess; pub use mock_model_server::create_mock_chat_completions_server; pub use mock_model_server::create_mock_chat_completions_server_unchecked; pub use responses::create_apply_patch_sse_response; pub use responses::create_final_assistant_message_sse_response; -pub use responses::create_shell_sse_response; +pub use responses::create_shell_command_sse_response; pub use rollout::create_fake_rollout; use serde::de::DeserializeOwned; diff --git a/codex-rs/app-server/tests/common/responses.rs b/codex-rs/app-server/tests/common/responses.rs index 9a827fb986..0a9183c043 100644 --- a/codex-rs/app-server/tests/common/responses.rs +++ b/codex-rs/app-server/tests/common/responses.rs @@ -1,17 +1,18 @@ use serde_json::json; use std::path::Path; -pub fn create_shell_sse_response( +pub fn create_shell_command_sse_response( command: Vec, workdir: Option<&Path>, timeout_ms: Option, call_id: &str, ) -> anyhow::Result { - // The `arguments`` for the `shell` tool is a serialized JSON object. + // The `arguments` for the `shell_command` tool is a serialized JSON object. + let command_str = shlex::try_join(command.iter().map(String::as_str))?; let tool_call_arguments = serde_json::to_string(&json!({ - "command": command, + "command": command_str, "workdir": workdir.map(|w| w.to_string_lossy()), - "timeout": timeout_ms + "timeout_ms": timeout_ms }))?; let tool_call = json!({ "choices": [ @@ -21,7 +22,7 @@ pub fn create_shell_sse_response( { "id": call_id, "function": { - "name": "shell", + "name": "shell_command", "arguments": tool_call_arguments } } @@ -62,10 +63,10 @@ pub fn create_apply_patch_sse_response( patch_content: &str, call_id: &str, ) -> anyhow::Result { - // Use shell command to call apply_patch with heredoc format - let shell_command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF"); + // Use shell_command to call apply_patch with heredoc format + let command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF"); let tool_call_arguments = serde_json::to_string(&json!({ - "command": ["bash", "-lc", shell_command] + "command": command }))?; let tool_call = json!({ @@ -76,7 +77,7 @@ pub fn create_apply_patch_sse_response( { "id": call_id, "function": { - "name": "shell", + "name": "shell_command", "arguments": tool_call_arguments } } diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index c8197a046d..52035e4ede 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -1,6 +1,8 @@ use anyhow::Result; use codex_protocol::ConversationId; +use codex_protocol::protocol::GitInfo; use codex_protocol::protocol::SessionMeta; +use codex_protocol::protocol::SessionMetaLine; use codex_protocol::protocol::SessionSource; use serde_json::json; use std::fs; @@ -22,6 +24,7 @@ pub fn create_fake_rollout( meta_rfc3339: &str, preview: &str, model_provider: Option<&str>, + git_info: Option, ) -> Result { let uuid = Uuid::new_v4(); let uuid_str = uuid.to_string(); @@ -37,7 +40,7 @@ pub fn create_fake_rollout( let file_path = dir.join(format!("rollout-{filename_ts}-{uuid}.jsonl")); // Build JSONL lines - let payload = serde_json::to_value(SessionMeta { + let meta = SessionMeta { id: conversation_id, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), @@ -46,6 +49,10 @@ pub fn create_fake_rollout( instructions: None, source: SessionSource::Cli, model_provider: model_provider.map(str::to_string), + }; + let payload = serde_json::to_value(SessionMetaLine { + meta, + git: git_info, })?; let lines = [ diff --git a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs index 1feda42841..a64aca8051 100644 --- a/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs +++ b/codex-rs/app-server/tests/suite/codex_message_processor_flow.rs @@ -2,7 +2,8 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_chat_completions_server; -use app_test_support::create_shell_sse_response; +use app_test_support::create_shell_command_sse_response; +use app_test_support::format_with_current_shell; use app_test_support::to_response; use codex_app_server_protocol::AddConversationListenerParams; use codex_app_server_protocol::AddConversationSubscriptionResponse; @@ -56,7 +57,7 @@ async fn test_codex_jsonrpc_conversation_flow() -> Result<()> { // Create a mock model server that immediately ends each turn. // Two turns are expected: initial session configure + one user message. let responses = vec![ - create_shell_sse_response( + create_shell_command_sse_response( vec!["ls".to_string()], Some(&working_directory), Some(5000), @@ -175,7 +176,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { // Mock server will request a python shell call for the first and second turn, then finish. let responses = vec![ - create_shell_sse_response( + create_shell_command_sse_response( vec![ "python3".to_string(), "-c".to_string(), @@ -186,7 +187,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { "call1", )?, create_final_assistant_message_sse_response("done 1")?, - create_shell_sse_response( + create_shell_command_sse_response( vec![ "python3".to_string(), "-c".to_string(), @@ -267,11 +268,7 @@ async fn test_send_user_turn_changes_approval_policy_behavior() -> Result<()> { ExecCommandApprovalParams { conversation_id, call_id: "call1".to_string(), - command: vec![ - "python3".to_string(), - "-c".to_string(), - "print(42)".to_string(), - ], + command: format_with_current_shell("python3 -c 'print(42)'"), cwd: working_directory.clone(), reason: None, risk: None, @@ -353,23 +350,15 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<( std::fs::create_dir(&second_cwd)?; let responses = vec![ - create_shell_sse_response( - vec![ - "bash".to_string(), - "-lc".to_string(), - "echo first turn".to_string(), - ], + create_shell_command_sse_response( + vec!["echo".to_string(), "first".to_string(), "turn".to_string()], None, Some(5000), "call-first", )?, create_final_assistant_message_sse_response("done first")?, - create_shell_sse_response( - vec![ - "bash".to_string(), - "-lc".to_string(), - "echo second turn".to_string(), - ], + create_shell_command_sse_response( + vec!["echo".to_string(), "second".to_string(), "turn".to_string()], None, Some(5000), "call-second", @@ -481,13 +470,9 @@ async fn test_send_user_turn_updates_sandbox_and_cwd_between_turns() -> Result<( exec_begin.cwd, second_cwd, "exec turn should run from updated cwd" ); + let expected_command = format_with_current_shell("echo second turn"); assert_eq!( - exec_begin.command, - vec![ - "bash".to_string(), - "-lc".to_string(), - "echo second turn".to_string() - ], + exec_begin.command, expected_command, "exec turn should run expected command" ); diff --git a/codex-rs/app-server/tests/suite/interrupt.rs b/codex-rs/app-server/tests/suite/interrupt.rs index 86b0a3f3f8..d8e6182be8 100644 --- a/codex-rs/app-server/tests/suite/interrupt.rs +++ b/codex-rs/app-server/tests/suite/interrupt.rs @@ -19,7 +19,7 @@ use tokio::time::timeout; use app_test_support::McpProcess; use app_test_support::create_mock_chat_completions_server; -use app_test_support::create_shell_sse_response; +use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); @@ -56,7 +56,7 @@ async fn shell_command_interruption() -> anyhow::Result<()> { std::fs::create_dir(&working_directory)?; // Create mock server with a single SSE response: the long sleep command - let server = create_mock_chat_completions_server(vec![create_shell_sse_response( + let server = create_mock_chat_completions_server(vec![create_shell_command_sse_response( shell_command.clone(), Some(&working_directory), Some(10_000), // 10 seconds timeout in ms diff --git a/codex-rs/app-server/tests/suite/list_resume.rs b/codex-rs/app-server/tests/suite/list_resume.rs index 30be93a2e6..1e89c06848 100644 --- a/codex-rs/app-server/tests/suite/list_resume.rs +++ b/codex-rs/app-server/tests/suite/list_resume.rs @@ -31,6 +31,7 @@ async fn test_list_and_resume_conversations() -> Result<()> { "2025-01-02T12:00:00Z", "Hello A", Some("openai"), + None, )?; create_fake_rollout( codex_home.path(), @@ -38,6 +39,7 @@ async fn test_list_and_resume_conversations() -> Result<()> { "2025-01-01T13:00:00Z", "Hello B", Some("openai"), + None, )?; create_fake_rollout( codex_home.path(), @@ -45,6 +47,7 @@ async fn test_list_and_resume_conversations() -> Result<()> { "2025-01-01T12:00:00Z", "Hello C", None, + None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -105,6 +108,7 @@ async fn test_list_and_resume_conversations() -> Result<()> { "2025-01-01T11:30:00Z", "Hello TP", Some("test-provider"), + None, )?; // Filtering by model provider should return only matching sessions. diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 464fb4eee8..57299ef97e 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -2,10 +2,14 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_fake_rollout; use app_test_support::to_response; +use codex_app_server_protocol::GitInfo as ApiGitInfo; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadListParams; use codex_app_server_protocol::ThreadListResponse; +use codex_protocol::protocol::GitInfo as CoreGitInfo; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -24,7 +28,7 @@ async fn thread_list_basic_empty() -> Result<()> { .send_thread_list_request(ThreadListParams { cursor: None, limit: Some(10), - model_providers: None, + model_providers: Some(vec!["mock_provider".to_string()]), }) .await?; let list_resp: JSONRPCResponse = timeout( @@ -63,6 +67,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { "2025-01-02T12:00:00Z", "Hello", Some("mock_provider"), + None, )?; let _b = create_fake_rollout( codex_home.path(), @@ -70,6 +75,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { "2025-01-01T13:00:00Z", "Hello", Some("mock_provider"), + None, )?; let _c = create_fake_rollout( codex_home.path(), @@ -77,6 +83,7 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { "2025-01-01T12:00:00Z", "Hello", Some("mock_provider"), + None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -104,6 +111,10 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { assert_eq!(thread.preview, "Hello"); assert_eq!(thread.model_provider, "mock_provider"); assert!(thread.created_at > 0); + assert_eq!(thread.cwd, PathBuf::from("/")); + assert_eq!(thread.cli_version, "0.0.0"); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.git_info, None); } let cursor1 = cursor1.expect("expected nextCursor on first page"); @@ -129,6 +140,10 @@ async fn thread_list_pagination_next_cursor_none_on_last_page() -> Result<()> { assert_eq!(thread.preview, "Hello"); assert_eq!(thread.model_provider, "mock_provider"); assert!(thread.created_at > 0); + assert_eq!(thread.cwd, PathBuf::from("/")); + assert_eq!(thread.cli_version, "0.0.0"); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.git_info, None); } assert_eq!(cursor2, None, "expected nextCursor to be null on last page"); @@ -147,6 +162,7 @@ async fn thread_list_respects_provider_filter() -> Result<()> { "2025-01-02T10:00:00Z", "X", Some("mock_provider"), + None, )?; // mock_provider let _b = create_fake_rollout( codex_home.path(), @@ -154,6 +170,7 @@ async fn thread_list_respects_provider_filter() -> Result<()> { "2025-01-02T11:00:00Z", "X", Some("other_provider"), + None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -180,6 +197,63 @@ async fn thread_list_respects_provider_filter() -> Result<()> { assert_eq!(thread.model_provider, "other_provider"); let expected_ts = chrono::DateTime::parse_from_rfc3339("2025-01-02T11:00:00Z")?.timestamp(); assert_eq!(thread.created_at, expected_ts); + assert_eq!(thread.cwd, PathBuf::from("/")); + assert_eq!(thread.cli_version, "0.0.0"); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.git_info, None); + + Ok(()) +} + +#[tokio::test] +async fn thread_list_includes_git_info() -> Result<()> { + let codex_home = TempDir::new()?; + create_minimal_config(codex_home.path())?; + + let git_info = CoreGitInfo { + commit_hash: Some("abc123".to_string()), + branch: Some("main".to_string()), + repository_url: Some("https://example.com/repo.git".to_string()), + }; + let conversation_id = create_fake_rollout( + codex_home.path(), + "2025-02-01T09-00-00", + "2025-02-01T09:00:00Z", + "Git info preview", + Some("mock_provider"), + Some(git_info), + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let list_id = mcp + .send_thread_list_request(ThreadListParams { + cursor: None, + limit: Some(10), + model_providers: Some(vec!["mock_provider".to_string()]), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(list_id)), + ) + .await??; + let ThreadListResponse { data, .. } = to_response::(resp)?; + let thread = data + .iter() + .find(|t| t.id == conversation_id) + .expect("expected thread for created rollout"); + + let expected_git = ApiGitInfo { + sha: Some("abc123".to_string()), + branch: Some("main".to_string()), + origin_url: Some("https://example.com/repo.git".to_string()), + }; + assert_eq!(thread.git_info, Some(expected_git)); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.cwd, PathBuf::from("/")); + assert_eq!(thread.cli_version, "0.0.0"); Ok(()) } diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index e22b711ea3..be8562e2fb 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -5,6 +5,7 @@ use app_test_support::create_mock_chat_completions_server; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SessionSource; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadResumeParams; use codex_app_server_protocol::ThreadResumeResponse; @@ -14,6 +15,7 @@ use codex_app_server_protocol::TurnStatus; use codex_app_server_protocol::UserInput; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; +use std::path::PathBuf; use tempfile::TempDir; use tokio::time::timeout; @@ -75,6 +77,7 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { "2025-01-05T12:00:00Z", preview, Some("mock_provider"), + None, )?; let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -97,6 +100,10 @@ async fn thread_resume_returns_rollout_history() -> Result<()> { assert_eq!(thread.preview, preview); assert_eq!(thread.model_provider, "mock_provider"); assert!(thread.path.is_absolute()); + assert_eq!(thread.cwd, PathBuf::from("/")); + assert_eq!(thread.cli_version, "0.0.0"); + assert_eq!(thread.source, SessionSource::Cli); + assert_eq!(thread.git_info, None); assert_eq!( thread.turns.len(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs index beafec9cfc..83389ed1d1 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_interrupt.rs @@ -3,7 +3,7 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::create_mock_chat_completions_server; -use app_test_support::create_shell_sse_response; +use app_test_support::create_shell_command_sse_response; use app_test_support::to_response; use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; @@ -41,7 +41,7 @@ async fn turn_interrupt_aborts_running_turn() -> Result<()> { std::fs::create_dir(&working_directory)?; // Mock server: long-running shell command then (after abort) nothing else needed. - let server = create_mock_chat_completions_server(vec![create_shell_sse_response( + let server = create_mock_chat_completions_server(vec![create_shell_command_sse_response( shell_command.clone(), Some(&working_directory), Some(10_000), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index b644315d97..de4d1cd2cd 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -4,9 +4,11 @@ use app_test_support::create_apply_patch_sse_response; use app_test_support::create_final_assistant_message_sse_response; use app_test_support::create_mock_chat_completions_server; use app_test_support::create_mock_chat_completions_server_unchecked; -use app_test_support::create_shell_sse_response; +use app_test_support::create_shell_command_sse_response; +use app_test_support::format_with_current_shell_display; use app_test_support::to_response; use codex_app_server_protocol::ApprovalDecision; +use codex_app_server_protocol::CommandExecutionRequestApprovalResponse; use codex_app_server_protocol::CommandExecutionStatus; use codex_app_server_protocol::FileChangeRequestApprovalResponse; use codex_app_server_protocol::ItemCompletedNotification; @@ -203,7 +205,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { // Mock server: first turn requests a shell call (elicitation), then completes. // Second turn same, but we'll set approval_policy=never to avoid elicitation. let responses = vec![ - create_shell_sse_response( + create_shell_command_sse_response( vec![ "python3".to_string(), "-c".to_string(), @@ -214,7 +216,7 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { "call1", )?, create_final_assistant_message_sse_response("done 1")?, - create_shell_sse_response( + create_shell_command_sse_response( vec![ "python3".to_string(), "-c".to_string(), @@ -328,6 +330,145 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_exec_approval_decline_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let tmp = TempDir::new()?; + let codex_home = tmp.path().to_path_buf(); + let workspace = tmp.path().join("workspace"); + std::fs::create_dir(&workspace)?; + + let responses = vec![ + create_shell_command_sse_response( + vec![ + "python3".to_string(), + "-c".to_string(), + "print(42)".to_string(), + ], + None, + Some(5000), + "call-decline", + )?, + create_final_assistant_message_sse_response("done")?, + ]; + let server = create_mock_chat_completions_server(responses).await; + create_config_toml(codex_home.as_path(), &server.uri(), "untrusted")?; + + let mut mcp = McpProcess::new(codex_home.as_path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let turn_id = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "run python".to_string(), + }], + cwd: Some(workspace.clone()), + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_id)), + ) + .await??; + let TurnStartResponse { turn } = to_response::(turn_resp)?; + + let started_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let started_notif = mcp + .read_stream_until_notification_message("item/started") + .await?; + let started: ItemStartedNotification = + serde_json::from_value(started_notif.params.clone().expect("item/started params"))?; + if let ThreadItem::CommandExecution { .. } = started.item { + return Ok::(started.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { id, status, .. } = started_command_execution else { + unreachable!("loop ensures we break on command execution items"); + }; + assert_eq!(id, "call-decline"); + assert_eq!(status, CommandExecutionStatus::InProgress); + + let server_req = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_request_message(), + ) + .await??; + let ServerRequest::CommandExecutionRequestApproval { request_id, params } = server_req else { + panic!("expected CommandExecutionRequestApproval request") + }; + assert_eq!(params.item_id, "call-decline"); + assert_eq!(params.thread_id, thread.id); + assert_eq!(params.turn_id, turn.id); + + mcp.send_response( + request_id, + serde_json::to_value(CommandExecutionRequestApprovalResponse { + decision: ApprovalDecision::Decline, + accept_settings: None, + })?, + ) + .await?; + + let completed_command_execution = timeout(DEFAULT_READ_TIMEOUT, async { + loop { + let completed_notif = mcp + .read_stream_until_notification_message("item/completed") + .await?; + let completed: ItemCompletedNotification = serde_json::from_value( + completed_notif + .params + .clone() + .expect("item/completed params"), + )?; + if let ThreadItem::CommandExecution { .. } = completed.item { + return Ok::(completed.item); + } + } + }) + .await??; + let ThreadItem::CommandExecution { + id, + status, + exit_code, + aggregated_output, + .. + } = completed_command_execution + else { + unreachable!("loop ensures we break on command execution items"); + }; + assert_eq!(id, "call-decline"); + assert_eq!(status, CommandExecutionStatus::Declined); + assert!(exit_code.is_none()); + assert!(aggregated_output.is_none()); + + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("codex/event/task_complete"), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { skip_if_no_network!(Ok(())); @@ -343,23 +484,15 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { std::fs::create_dir(&second_cwd)?; let responses = vec![ - create_shell_sse_response( - vec![ - "bash".to_string(), - "-lc".to_string(), - "echo first turn".to_string(), - ], + create_shell_command_sse_response( + vec!["echo".to_string(), "first".to_string(), "turn".to_string()], None, Some(5000), "call-first", )?, create_final_assistant_message_sse_response("done first")?, - create_shell_sse_response( - vec![ - "bash".to_string(), - "-lc".to_string(), - "echo second turn".to_string(), - ], + create_shell_command_sse_response( + vec!["echo".to_string(), "second".to_string(), "turn".to_string()], None, Some(5000), "call-second", @@ -465,7 +598,8 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { unreachable!("loop ensures we break on command execution items"); }; assert_eq!(cwd, second_cwd); - assert_eq!(command, "bash -lc 'echo second turn'"); + let expected_command = format_with_current_shell_display("echo second turn"); + assert_eq!(command, expected_command); assert_eq!(status, CommandExecutionStatus::InProgress); timeout( @@ -480,6 +614,10 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { #[tokio::test] async fn turn_start_file_change_approval_v2() -> Result<()> { skip_if_no_network!(Ok(())); + if cfg!(windows) { + // TODO apply_patch approvals are not parsed from powershell commands yet + return Ok(()); + } let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); @@ -626,6 +764,10 @@ async fn turn_start_file_change_approval_v2() -> Result<()> { #[tokio::test] async fn turn_start_file_change_approval_decline_v2() -> Result<()> { skip_if_no_network!(Ok(())); + if cfg!(windows) { + // TODO apply_patch approvals are not parsed from powershell commands yet + return Ok(()); + } let tmp = TempDir::new()?; let codex_home = tmp.path().join("codex_home"); diff --git a/codex-rs/apply-patch/src/lib.rs b/codex-rs/apply-patch/src/lib.rs index ac2f409791..a2bea0514a 100644 --- a/codex-rs/apply-patch/src/lib.rs +++ b/codex-rs/apply-patch/src/lib.rs @@ -30,6 +30,7 @@ pub use standalone_executable::main; pub const APPLY_PATCH_TOOL_INSTRUCTIONS: &str = include_str!("../apply_patch_tool_instructions.md"); const APPLY_PATCH_COMMANDS: [&str; 2] = ["apply_patch", "applypatch"]; +const APPLY_PATCH_SHELLS: [&str; 3] = ["bash", "zsh", "sh"]; #[derive(Debug, Error, PartialEq)] pub enum ApplyPatchError { @@ -96,6 +97,13 @@ pub struct ApplyPatchArgs { pub workdir: Option, } +fn shell_supports_apply_patch(shell: &str) -> bool { + std::path::Path::new(shell) + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| APPLY_PATCH_SHELLS.contains(&name)) +} + pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { match argv { // Direct invocation: apply_patch @@ -104,7 +112,7 @@ pub fn maybe_parse_apply_patch(argv: &[String]) -> MaybeApplyPatch { Err(e) => MaybeApplyPatch::PatchParseError(e), }, // Bash heredoc form: (optional `cd &&`) apply_patch <<'EOF' ... - [bash, flag, script] if bash == "bash" && flag == "-lc" => { + [shell, flag, script] if shell_supports_apply_patch(shell) && flag == "-lc" => { match extract_apply_patch_from_bash(script) { Ok((body, workdir)) => match parse_patch(&body) { Ok(mut source) => { @@ -224,12 +232,12 @@ pub fn maybe_parse_apply_patch_verified(argv: &[String], cwd: &Path) -> MaybeApp ); } } - [bash, flag, script] if bash == "bash" && flag == "-lc" => { - if parse_patch(script).is_ok() { - return MaybeApplyPatchVerified::CorrectnessError( - ApplyPatchError::ImplicitInvocation, - ); - } + [shell, flag, script] + if shell_supports_apply_patch(shell) + && flag == "-lc" + && parse_patch(script).is_ok() => + { + return MaybeApplyPatchVerified::CorrectnessError(ApplyPatchError::ImplicitInvocation); } _ => {} } diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 8b8292cd16..45e3d87aca 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -543,7 +543,6 @@ impl Session { config.model_reasoning_effort, config.model_reasoning_summary, config.model_context_window, - config.model_max_output_tokens, config.model_auto_compact_token_limit, config.approval_policy, config.sandbox_policy.clone(), @@ -1191,12 +1190,12 @@ impl Session { message: impl Into, codex_error: CodexErr, ) { - let codex_error_code = CodexErrorInfo::ResponseStreamDisconnected { + let codex_error_info = CodexErrorInfo::ResponseStreamDisconnected { http_status_code: codex_error.http_status_code_value(), }; let event = EventMsg::StreamError(StreamErrorEvent { message: message.into(), - codex_error_code: Some(codex_error_code), + codex_error_info: Some(codex_error_info), }); self.send_event(turn_context, event).await; } @@ -1689,7 +1688,7 @@ mod handlers { id: sub_id.clone(), msg: EventMsg::Error(ErrorEvent { message: "Failed to shutdown rollout recorder".to_string(), - codex_error_code: Some(CodexErrorInfo::Other), + codex_error_info: Some(CodexErrorInfo::Other), }), }; sess.send_event_raw(event).await; @@ -3051,6 +3050,7 @@ mod tests { let session = Arc::new(session); let mut turn_context = Arc::new(turn_context_raw); + let timeout_ms = 1000; let params = ExecParams { command: if cfg!(windows) { vec![ @@ -3066,7 +3066,7 @@ mod tests { ] }, cwd: turn_context.cwd.clone(), - timeout_ms: Some(1000), + expiration: timeout_ms.into(), env: HashMap::new(), with_escalated_permissions: Some(true), justification: Some("test".to_string()), @@ -3075,7 +3075,12 @@ mod tests { let params2 = ExecParams { with_escalated_permissions: Some(false), - ..params.clone() + command: params.command.clone(), + cwd: params.cwd.clone(), + expiration: timeout_ms.into(), + env: HashMap::new(), + justification: params.justification.clone(), + arg0: None, }; let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); @@ -3095,7 +3100,7 @@ mod tests { arguments: serde_json::json!({ "command": params.command.clone(), "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params.timeout_ms, + "timeout_ms": params.expiration.timeout_ms(), "with_escalated_permissions": params.with_escalated_permissions, "justification": params.justification.clone(), }) @@ -3132,7 +3137,7 @@ mod tests { arguments: serde_json::json!({ "command": params2.command.clone(), "workdir": Some(turn_context.cwd.to_string_lossy().to_string()), - "timeout_ms": params2.timeout_ms, + "timeout_ms": params2.expiration.timeout_ms(), "with_escalated_permissions": params2.with_escalated_permissions, "justification": params2.justification.clone(), }) diff --git a/codex-rs/core/src/command_safety/is_safe_command.rs b/codex-rs/core/src/command_safety/is_safe_command.rs index ab084c191b..5e7a7034d8 100644 --- a/codex-rs/core/src/command_safety/is_safe_command.rs +++ b/codex-rs/core/src/command_safety/is_safe_command.rs @@ -267,6 +267,20 @@ mod tests { } } + #[test] + fn windows_powershell_full_path_is_safe() { + if !cfg!(windows) { + // Windows only because on Linux path splitting doesn't handle `/` separators properly + return; + } + + assert!(is_known_safe_command(&vec_str(&[ + r"C:\Program Files\PowerShell\7\pwsh.exe", + "-Command", + "Get-Location", + ]))); + } + #[test] fn bash_lc_safe_examples() { assert!(is_known_safe_command(&vec_str(&["bash", "-lc", "ls"]))); diff --git a/codex-rs/core/src/command_safety/windows_safe_commands.rs b/codex-rs/core/src/command_safety/windows_safe_commands.rs index ff0a3d2e7b..a1d3b297f9 100644 --- a/codex-rs/core/src/command_safety/windows_safe_commands.rs +++ b/codex-rs/core/src/command_safety/windows_safe_commands.rs @@ -1,4 +1,5 @@ use shlex::split as shlex_split; +use std::path::Path; /// On Windows, we conservatively allow only clearly read-only PowerShell invocations /// that match a small safelist. Anything else (including direct CMD commands) is unsafe. @@ -131,8 +132,14 @@ fn split_into_commands(tokens: Vec) -> Option>> { /// Returns true when the executable name is one of the supported PowerShell binaries. fn is_powershell_executable(exe: &str) -> bool { + let executable_name = Path::new(exe) + .file_name() + .and_then(|osstr| osstr.to_str()) + .unwrap_or(exe) + .to_ascii_lowercase(); + matches!( - exe.to_ascii_lowercase().as_str(), + executable_name.as_str(), "powershell" | "powershell.exe" | "pwsh" | "pwsh.exe" ) } @@ -313,6 +320,27 @@ mod tests { ]))); } + #[test] + fn accepts_full_path_powershell_invocations() { + if !cfg!(windows) { + // Windows only because on Linux path splitting doesn't handle `/` separators properly + return; + } + + assert!(is_safe_command_windows(&vec_str(&[ + r"C:\Program Files\PowerShell\7\pwsh.exe", + "-NoProfile", + "-Command", + "Get-ChildItem -Path .", + ]))); + + assert!(is_safe_command_windows(&vec_str(&[ + r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe", + "-Command", + "Get-Content Cargo.toml", + ]))); + } + #[test] fn allows_read_only_pipelines_and_git_usage() { assert!(is_safe_command_windows(&vec_str(&[ diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 4de970e262..b52a628a97 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -86,9 +86,6 @@ pub struct Config { /// Size of the context window for the model, in tokens. pub model_context_window: Option, - /// Maximum number of output tokens. - pub model_max_output_tokens: Option, - /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, @@ -569,9 +566,6 @@ pub struct ConfigToml { /// Size of the context window for the model, in tokens. pub model_context_window: Option, - /// Maximum number of output tokens. - pub model_max_output_tokens: Option, - /// Token usage threshold triggering auto-compaction of conversation history. pub model_auto_compact_token_limit: Option, @@ -1121,11 +1115,6 @@ impl Config { let model_context_window = cfg .model_context_window .or_else(|| openai_model_info.as_ref().map(|info| info.context_window)); - let model_max_output_tokens = cfg.model_max_output_tokens.or_else(|| { - openai_model_info - .as_ref() - .map(|info| info.max_output_tokens) - }); let model_auto_compact_token_limit = cfg.model_auto_compact_token_limit.or_else(|| { openai_model_info .as_ref() @@ -1177,7 +1166,6 @@ impl Config { review_model, model_family, model_context_window, - model_max_output_tokens, model_auto_compact_token_limit, model_provider_id, model_provider, @@ -2960,7 +2948,6 @@ model_verbosity = "high" review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_family: find_family_for_model("o3").expect("known model slug"), model_context_window: Some(200_000), - model_max_output_tokens: Some(100_000), model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), @@ -3033,7 +3020,6 @@ model_verbosity = "high" review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_family: find_family_for_model("gpt-3.5-turbo").expect("known model slug"), model_context_window: Some(16_385), - model_max_output_tokens: Some(4_096), model_auto_compact_token_limit: Some(14_746), model_provider_id: "openai-chat-completions".to_string(), model_provider: fixture.openai_chat_completions_provider.clone(), @@ -3121,7 +3107,6 @@ model_verbosity = "high" review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_family: find_family_for_model("o3").expect("known model slug"), model_context_window: Some(200_000), - model_max_output_tokens: Some(100_000), model_auto_compact_token_limit: Some(180_000), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), @@ -3195,7 +3180,6 @@ model_verbosity = "high" review_model: OPENAI_DEFAULT_REVIEW_MODEL.to_string(), model_family: find_family_for_model("gpt-5.1").expect("known model slug"), model_context_window: Some(272_000), - model_max_output_tokens: Some(128_000), model_auto_compact_token_limit: Some(244_800), model_provider_id: "openai".to_string(), model_provider: fixture.openai_provider.clone(), diff --git a/codex-rs/core/src/error.rs b/codex-rs/core/src/error.rs index ee72930499..9130b40e1e 100644 --- a/codex-rs/core/src/error.rs +++ b/codex-rs/core/src/error.rs @@ -469,7 +469,7 @@ impl CodexErr { }; ErrorEvent { message, - codex_error_code: Some(self.to_codex_protocol_error()), + codex_error_info: Some(self.to_codex_protocol_error()), } } @@ -650,7 +650,7 @@ mod tests { "prefix: Error while reading the server response: HTTP status client error (429 Too Many Requests) for url (http://example.com/), request id: req-123" ); assert_eq!( - event.codex_error_code, + event.codex_error_info, Some(CodexErrorInfo::ResponseStreamConnectionFailed { http_status_code: Some(429) }) diff --git a/codex-rs/core/src/event_mapping.rs b/codex-rs/core/src/event_mapping.rs index d0aa1d8188..6b4bed4db3 100644 --- a/codex-rs/core/src/event_mapping.rs +++ b/codex-rs/core/src/event_mapping.rs @@ -117,7 +117,7 @@ pub fn parse_turn_item(item: &ResponseItem) -> Option { .. } => Some(TurnItem::WebSearch(WebSearchItem { id: id.clone().unwrap_or_default(), - query: query.clone(), + query: query.clone().unwrap_or_default(), })), _ => None, } @@ -306,7 +306,7 @@ mod tests { id: Some("ws_1".to_string()), status: Some("completed".to_string()), action: WebSearchAction::Search { - query: "weather".to_string(), + query: Some("weather".to_string()), }, }; diff --git a/codex-rs/core/src/exec.rs b/codex-rs/core/src/exec.rs index ed64d07dbf..2530b52742 100644 --- a/codex-rs/core/src/exec.rs +++ b/codex-rs/core/src/exec.rs @@ -14,6 +14,7 @@ use tokio::io::AsyncRead; use tokio::io::AsyncReadExt; use tokio::io::BufReader; use tokio::process::Child; +use tokio_util::sync::CancellationToken; use crate::error::CodexErr; use crate::error::Result; @@ -30,7 +31,7 @@ use crate::spawn::StdioPolicy; use crate::spawn::spawn_child_async; use crate::text_encoding::bytes_to_string_smart; -const DEFAULT_TIMEOUT_MS: u64 = 10_000; +pub const DEFAULT_EXEC_COMMAND_TIMEOUT_MS: u64 = 10_000; // Hardcode these since it does not seem worth including the libc crate just // for these. @@ -47,20 +48,59 @@ const AGGREGATE_BUFFER_INITIAL_CAPACITY: usize = 8 * 1024; // 8 KiB /// Aggregation still collects full output; only the live event stream is capped. pub(crate) const MAX_EXEC_OUTPUT_DELTAS_PER_CALL: usize = 10_000; -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct ExecParams { pub command: Vec, pub cwd: PathBuf, - pub timeout_ms: Option, + pub expiration: ExecExpiration, pub env: HashMap, pub with_escalated_permissions: Option, pub justification: Option, pub arg0: Option, } -impl ExecParams { - pub fn timeout_duration(&self) -> Duration { - Duration::from_millis(self.timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS)) +/// Mechanism to terminate an exec invocation before it finishes naturally. +#[derive(Debug)] +pub enum ExecExpiration { + Timeout(Duration), + DefaultTimeout, + Cancellation(CancellationToken), +} + +impl From> for ExecExpiration { + fn from(timeout_ms: Option) -> Self { + timeout_ms.map_or(ExecExpiration::DefaultTimeout, |timeout_ms| { + ExecExpiration::Timeout(Duration::from_millis(timeout_ms)) + }) + } +} + +impl From for ExecExpiration { + fn from(timeout_ms: u64) -> Self { + ExecExpiration::Timeout(Duration::from_millis(timeout_ms)) + } +} + +impl ExecExpiration { + async fn wait(self) { + match self { + ExecExpiration::Timeout(duration) => tokio::time::sleep(duration).await, + ExecExpiration::DefaultTimeout => { + tokio::time::sleep(Duration::from_millis(DEFAULT_EXEC_COMMAND_TIMEOUT_MS)).await + } + ExecExpiration::Cancellation(cancel) => { + cancel.cancelled().await; + } + } + } + + /// If ExecExpiration is a timeout, returns the timeout in milliseconds. + pub(crate) fn timeout_ms(&self) -> Option { + match self { + ExecExpiration::Timeout(duration) => Some(duration.as_millis() as u64), + ExecExpiration::DefaultTimeout => Some(DEFAULT_EXEC_COMMAND_TIMEOUT_MS), + ExecExpiration::Cancellation(_) => None, + } } } @@ -82,7 +122,7 @@ pub async fn process_exec_tool_call( let ExecParams { command, cwd, - timeout_ms, + expiration, env, with_escalated_permissions, justification, @@ -101,7 +141,7 @@ pub async fn process_exec_tool_call( args: args.to_vec(), cwd, env, - timeout_ms, + expiration, with_escalated_permissions, justification, }; @@ -109,7 +149,7 @@ pub async fn process_exec_tool_call( let manager = SandboxManager::new(); let exec_env = manager .transform( - &spec, + spec, sandbox_policy, sandboxed, sandbox_cwd, @@ -118,7 +158,7 @@ pub async fn process_exec_tool_call( .map_err(CodexErr::from)?; // Route through the sandboxing module for a single, unified execution path. - crate::sandboxing::execute_env(&exec_env, sandbox_policy, stdout_stream).await + crate::sandboxing::execute_env(exec_env, sandbox_policy, stdout_stream).await } pub(crate) async fn execute_exec_env( @@ -130,7 +170,7 @@ pub(crate) async fn execute_exec_env( command, cwd, env, - timeout_ms, + expiration, sandboxed, with_escalated_permissions, justification, @@ -140,7 +180,7 @@ pub(crate) async fn execute_exec_env( let params = ExecParams { command, cwd, - timeout_ms, + expiration, env, with_escalated_permissions, justification, @@ -165,9 +205,12 @@ async fn exec_windows_sandbox( command, cwd, env, - timeout_ms, + expiration, .. } = params; + // TODO(iceweasel-oai): run_windows_sandbox_capture should support all + // variants of ExecExpiration, not just timeout. + let timeout_ms = expiration.timeout_ms(); let policy_str = serde_json::to_string(sandbox_policy).map_err(|err| { CodexErr::Io(io::Error::other(format!( @@ -432,12 +475,12 @@ async fn exec( if sandboxed && !matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) { return exec_windows_sandbox(params, sandbox_policy).await; } - let timeout = params.timeout_duration(); let ExecParams { command, cwd, env, arg0, + expiration, .. } = params; @@ -458,14 +501,14 @@ async fn exec( env, ) .await?; - consume_truncated_output(child, timeout, stdout_stream).await + consume_truncated_output(child, expiration, stdout_stream).await } /// Consumes the output of a child process, truncating it so it is suitable for /// use as the output of a `shell` tool call. Also enforces specified timeout. async fn consume_truncated_output( mut child: Child, - timeout: Duration, + expiration: ExecExpiration, stdout_stream: Option, ) -> Result { // Both stdout and stderr were configured with `Stdio::piped()` @@ -499,20 +542,14 @@ async fn consume_truncated_output( )); let (exit_status, timed_out) = tokio::select! { - result = tokio::time::timeout(timeout, child.wait()) => { - match result { - Ok(status_result) => { - let exit_status = status_result?; - (exit_status, false) - } - Err(_) => { - // timeout - kill_child_process_group(&mut child)?; - child.start_kill()?; - // Debatable whether `child.wait().await` should be called here. - (synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE), true) - } - } + status_result = child.wait() => { + let exit_status = status_result?; + (exit_status, false) + } + _ = expiration.wait() => { + kill_child_process_group(&mut child)?; + child.start_kill()?; + (synthetic_exit_status(EXIT_CODE_SIGNAL_BASE + TIMEOUT_CODE), true) } _ = tokio::signal::ctrl_c() => { kill_child_process_group(&mut child)?; @@ -767,7 +804,7 @@ mod tests { let params = ExecParams { command, cwd: std::env::current_dir()?, - timeout_ms: Some(500), + expiration: 500.into(), env, with_escalated_permissions: None, justification: None, @@ -801,4 +838,62 @@ mod tests { assert!(killed, "grandchild process with pid {pid} is still alive"); Ok(()) } + + #[tokio::test] + async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> { + let command = long_running_command(); + let cwd = std::env::current_dir()?; + let env: HashMap = std::env::vars().collect(); + let cancel_token = CancellationToken::new(); + let cancel_tx = cancel_token.clone(); + let params = ExecParams { + command, + cwd: cwd.clone(), + expiration: ExecExpiration::Cancellation(cancel_token), + env, + with_escalated_permissions: None, + justification: None, + arg0: None, + }; + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(1_000)).await; + cancel_tx.cancel(); + }); + let result = process_exec_tool_call( + params, + SandboxType::None, + &SandboxPolicy::DangerFullAccess, + cwd.as_path(), + &None, + None, + ) + .await; + let output = match result { + Err(CodexErr::Sandbox(SandboxErr::Timeout { output })) => output, + other => panic!("expected timeout error, got {other:?}"), + }; + assert!(output.timed_out); + assert_eq!(output.exit_code, EXEC_TIMEOUT_EXIT_CODE); + Ok(()) + } + + #[cfg(unix)] + fn long_running_command() -> Vec { + vec![ + "/bin/sh".to_string(), + "-c".to_string(), + "sleep 30".to_string(), + ] + } + + #[cfg(windows)] + fn long_running_command() -> Vec { + vec![ + "powershell.exe".to_string(), + "-NonInteractive".to_string(), + "-NoLogo".to_string(), + "-Command".to_string(), + "Start-Sleep -Seconds 30".to_string(), + ] + } } diff --git a/codex-rs/core/src/git_info.rs b/codex-rs/core/src/git_info.rs index 387e9a682e..34e0afc729 100644 --- a/codex-rs/core/src/git_info.rs +++ b/codex-rs/core/src/git_info.rs @@ -825,11 +825,21 @@ mod tests { .await .expect("Should collect git info from repo"); + let remote_url_output = Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(&repo_path) + .output() + .await + .expect("Failed to read remote url"); + // Some dev environments rewrite remotes (e.g., force SSH), so compare against + // whatever URL Git reports instead of a fixed placeholder. + let expected_remote = String::from_utf8(remote_url_output.stdout) + .unwrap() + .trim() + .to_string(); + // Should have repository URL - assert_eq!( - git_info.repository_url, - Some("https://github.com/example/repo.git".to_string()) - ); + assert_eq!(git_info.repository_url, Some(expected_remote)); } #[tokio::test] diff --git a/codex-rs/core/src/openai_model_info.rs b/codex-rs/core/src/openai_model_info.rs index bd0ca9d31d..0ae3f03eb5 100644 --- a/codex-rs/core/src/openai_model_info.rs +++ b/codex-rs/core/src/openai_model_info.rs @@ -2,7 +2,6 @@ use crate::model_family::ModelFamily; // Shared constants for commonly used window/token sizes. pub(crate) const CONTEXT_WINDOW_272K: i64 = 272_000; -pub(crate) const MAX_OUTPUT_TOKENS_128K: i64 = 128_000; /// Metadata about a model, particularly OpenAI models. /// We may want to consider including details like the pricing for @@ -14,19 +13,15 @@ pub(crate) struct ModelInfo { /// Size of the context window in tokens. This is the maximum size of the input context. pub(crate) context_window: i64, - /// Maximum number of output tokens that can be generated for the model. - pub(crate) max_output_tokens: i64, - /// Token threshold where we should automatically compact conversation history. This considers /// input tokens + output tokens of this turn. pub(crate) auto_compact_token_limit: Option, } impl ModelInfo { - const fn new(context_window: i64, max_output_tokens: i64) -> Self { + const fn new(context_window: i64) -> Self { Self { context_window, - max_output_tokens, auto_compact_token_limit: Some(Self::default_auto_compact_limit(context_window)), } } @@ -42,48 +37,44 @@ pub(crate) fn get_model_info(model_family: &ModelFamily) -> Option { // OSS models have a 128k shared token pool. // Arbitrarily splitting it: 3/4 input context, 1/4 output. // https://openai.com/index/gpt-oss-model-card/ - "gpt-oss-20b" => Some(ModelInfo::new(96_000, 32_000)), - "gpt-oss-120b" => Some(ModelInfo::new(96_000, 32_000)), + "gpt-oss-20b" => Some(ModelInfo::new(96_000)), + "gpt-oss-120b" => Some(ModelInfo::new(96_000)), // https://platform.openai.com/docs/models/o3 - "o3" => Some(ModelInfo::new(200_000, 100_000)), + "o3" => Some(ModelInfo::new(200_000)), // https://platform.openai.com/docs/models/o4-mini - "o4-mini" => Some(ModelInfo::new(200_000, 100_000)), + "o4-mini" => Some(ModelInfo::new(200_000)), // https://platform.openai.com/docs/models/codex-mini-latest - "codex-mini-latest" => Some(ModelInfo::new(200_000, 100_000)), + "codex-mini-latest" => Some(ModelInfo::new(200_000)), // As of Jun 25, 2025, gpt-4.1 defaults to gpt-4.1-2025-04-14. // https://platform.openai.com/docs/models/gpt-4.1 - "gpt-4.1" | "gpt-4.1-2025-04-14" => Some(ModelInfo::new(1_047_576, 32_768)), + "gpt-4.1" | "gpt-4.1-2025-04-14" => Some(ModelInfo::new(1_047_576)), // As of Jun 25, 2025, gpt-4o defaults to gpt-4o-2024-08-06. // https://platform.openai.com/docs/models/gpt-4o - "gpt-4o" | "gpt-4o-2024-08-06" => Some(ModelInfo::new(128_000, 16_384)), + "gpt-4o" | "gpt-4o-2024-08-06" => Some(ModelInfo::new(128_000)), // https://platform.openai.com/docs/models/gpt-4o?snapshot=gpt-4o-2024-05-13 - "gpt-4o-2024-05-13" => Some(ModelInfo::new(128_000, 4_096)), + "gpt-4o-2024-05-13" => Some(ModelInfo::new(128_000)), // https://platform.openai.com/docs/models/gpt-4o?snapshot=gpt-4o-2024-11-20 - "gpt-4o-2024-11-20" => Some(ModelInfo::new(128_000, 16_384)), + "gpt-4o-2024-11-20" => Some(ModelInfo::new(128_000)), // https://platform.openai.com/docs/models/gpt-3.5-turbo - "gpt-3.5-turbo" => Some(ModelInfo::new(16_385, 4_096)), + "gpt-3.5-turbo" => Some(ModelInfo::new(16_385)), _ if slug.starts_with("gpt-5-codex") || slug.starts_with("gpt-5.1-codex") || slug.starts_with("gpt-5.1-codex-max") => { - Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) + Some(ModelInfo::new(CONTEXT_WINDOW_272K)) } - _ if slug.starts_with("gpt-5") => { - Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) - } + _ if slug.starts_with("gpt-5") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), - _ if slug.starts_with("codex-") => { - Some(ModelInfo::new(CONTEXT_WINDOW_272K, MAX_OUTPUT_TOKENS_128K)) - } + _ if slug.starts_with("codex-") => Some(ModelInfo::new(CONTEXT_WINDOW_272K)), _ => None, } diff --git a/codex-rs/core/src/sandboxing/mod.rs b/codex-rs/core/src/sandboxing/mod.rs index 30dcabf533..944c9e1d72 100644 --- a/codex-rs/core/src/sandboxing/mod.rs +++ b/codex-rs/core/src/sandboxing/mod.rs @@ -8,6 +8,7 @@ pub mod assessment; pub mod linux; pub mod mac; +use crate::exec::ExecExpiration; use crate::exec::ExecToolCallOutput; use crate::exec::StdoutStream; use crate::exec::execute_exec_env; @@ -54,23 +55,23 @@ impl From for SandboxPermissions { } } -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct CommandSpec { pub program: String, pub args: Vec, pub cwd: PathBuf, pub env: HashMap, - pub timeout_ms: Option, + pub expiration: ExecExpiration, pub with_escalated_permissions: Option, pub justification: Option, } -#[derive(Clone, Debug)] +#[derive(Debug)] pub struct ExecEnv { pub command: Vec, pub cwd: PathBuf, pub env: HashMap, - pub timeout_ms: Option, + pub expiration: ExecExpiration, pub sandboxed: bool, pub with_escalated_permissions: Option, pub justification: Option, @@ -119,13 +120,13 @@ impl SandboxManager { pub(crate) fn transform( &self, - spec: &CommandSpec, + mut spec: CommandSpec, policy: &SandboxPolicy, sandboxed: bool, sandbox_policy_cwd: &Path, codex_linux_sandbox_exe: Option<&PathBuf>, ) -> Result { - let mut env = spec.env.clone(); + let mut env = spec.env; if !policy.has_full_network_access() { env.insert( CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR.to_string(), @@ -134,15 +135,15 @@ impl SandboxManager { } let mut command = Vec::with_capacity(1 + spec.args.len()); - command.push(spec.program.clone()); - command.extend(spec.args.iter().cloned()); + command.push(spec.program); + command.append(&mut spec.args); if !sandboxed { return Ok(ExecEnv { command, cwd: spec.cwd.clone(), env, - timeout_ms: spec.timeout_ms, + expiration: spec.expiration, sandboxed, with_escalated_permissions: spec.with_escalated_permissions, justification: spec.justification.clone(), @@ -157,12 +158,12 @@ impl SandboxManager { Ok(ExecEnv { command, - cwd: spec.cwd.clone(), + cwd: spec.cwd, env, - timeout_ms: spec.timeout_ms, + expiration: spec.expiration, sandboxed, with_escalated_permissions: spec.with_escalated_permissions, - justification: spec.justification.clone(), + justification: spec.justification, arg0: arg0_override, }) } @@ -224,9 +225,9 @@ impl SandboxManager { } pub async fn execute_env( - env: &ExecEnv, + env: ExecEnv, policy: &SandboxPolicy, stdout_stream: Option, ) -> crate::error::Result { - execute_exec_env(env.clone(), policy, stdout_stream).await + execute_exec_env(env, policy, stdout_stream).await } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 3ebc29070c..8e4d6d4832 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -94,7 +94,9 @@ impl SessionTask for UserShellCommandTask { command: command.clone(), cwd: cwd.clone(), env: create_env(&turn_context.shell_environment_policy), - timeout_ms: Some(USER_SHELL_TIMEOUT_MS), + // TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we + // should use that instead of an "arbitrarily large" timeout here. + expiration: USER_SHELL_TIMEOUT_MS.into(), sandboxed: false, with_escalated_permissions: None, justification: None, diff --git a/codex-rs/core/src/tools/handlers/shell.rs b/codex-rs/core/src/tools/handlers/shell.rs index 3cdf8af57d..99c822fa56 100644 --- a/codex-rs/core/src/tools/handlers/shell.rs +++ b/codex-rs/core/src/tools/handlers/shell.rs @@ -37,7 +37,7 @@ impl ShellHandler { ExecParams { command: params.command, cwd: turn_context.resolve_path(params.workdir.clone()), - timeout_ms: params.timeout_ms, + expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy), with_escalated_permissions: params.with_escalated_permissions, justification: params.justification, @@ -59,7 +59,7 @@ impl ShellCommandHandler { ExecParams { command, cwd: turn_context.resolve_path(params.workdir.clone()), - timeout_ms: params.timeout_ms, + expiration: params.timeout_ms.into(), env: create_env(&turn_context.shell_environment_policy), with_escalated_permissions: params.with_escalated_permissions, justification: params.justification, @@ -243,7 +243,7 @@ impl ShellHandler { let req = ApplyPatchRequest { patch: apply.action.patch.clone(), cwd: apply.action.cwd.clone(), - timeout_ms: exec_params.timeout_ms, + timeout_ms: exec_params.expiration.timeout_ms(), user_explicitly_approved: apply.user_explicitly_approved_this_action, codex_exe: turn.codex_linux_sandbox_exe.clone(), }; @@ -300,7 +300,7 @@ impl ShellHandler { let req = ShellRequest { command: exec_params.command.clone(), cwd: exec_params.cwd.clone(), - timeout_ms: exec_params.timeout_ms, + timeout_ms: exec_params.expiration.timeout_ms(), env: exec_params.env.clone(), with_escalated_permissions: exec_params.with_escalated_permissions, justification: exec_params.justification.clone(), diff --git a/codex-rs/core/src/tools/runtimes/apply_patch.rs b/codex-rs/core/src/tools/runtimes/apply_patch.rs index 0cdddd5087..2334f1e712 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch.rs @@ -67,7 +67,7 @@ impl ApplyPatchRuntime { program, args: vec![CODEX_APPLY_PATCH_ARG1.to_string(), req.patch.clone()], cwd: req.cwd.clone(), - timeout_ms: req.timeout_ms, + expiration: req.timeout_ms.into(), // Run apply_patch with a minimal environment for determinism and to avoid leaks. env: HashMap::new(), with_escalated_permissions: None, @@ -153,9 +153,9 @@ impl ToolRuntime for ApplyPatchRuntime { ) -> Result { let spec = Self::build_command_spec(req)?; let env = attempt - .env_for(&spec) + .env_for(spec) .map_err(|err| ToolError::Codex(err.into()))?; - let out = execute_env(&env, attempt.policy, Self::stdout_stream(ctx)) + let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx)) .await .map_err(ToolError::Codex)?; Ok(out) diff --git a/codex-rs/core/src/tools/runtimes/mod.rs b/codex-rs/core/src/tools/runtimes/mod.rs index 212163d72c..437f4af428 100644 --- a/codex-rs/core/src/tools/runtimes/mod.rs +++ b/codex-rs/core/src/tools/runtimes/mod.rs @@ -4,6 +4,7 @@ Module: runtimes Concrete ToolRuntime implementations for specific tools. Each runtime stays small and focused and reuses the orchestrator for approvals + sandbox + retry. */ +use crate::exec::ExecExpiration; use crate::sandboxing::CommandSpec; use crate::tools::sandboxing::ToolError; use std::collections::HashMap; @@ -19,7 +20,7 @@ pub(crate) fn build_command_spec( command: &[String], cwd: &Path, env: &HashMap, - timeout_ms: Option, + expiration: ExecExpiration, with_escalated_permissions: Option, justification: Option, ) -> Result { @@ -31,7 +32,7 @@ pub(crate) fn build_command_spec( args: args.to_vec(), cwd: cwd.to_path_buf(), env: env.clone(), - timeout_ms, + expiration, with_escalated_permissions, justification, }) diff --git a/codex-rs/core/src/tools/runtimes/shell.rs b/codex-rs/core/src/tools/runtimes/shell.rs index d71c4498e6..b46f72b485 100644 --- a/codex-rs/core/src/tools/runtimes/shell.rs +++ b/codex-rs/core/src/tools/runtimes/shell.rs @@ -133,14 +133,14 @@ impl ToolRuntime for ShellRuntime { &req.command, &req.cwd, &req.env, - req.timeout_ms, + req.timeout_ms.into(), req.with_escalated_permissions, req.justification.clone(), )?; let env = attempt - .env_for(&spec) + .env_for(spec) .map_err(|err| ToolError::Codex(err.into()))?; - let out = execute_env(&env, attempt.policy, Self::stdout_stream(ctx)) + let out = execute_env(env, attempt.policy, Self::stdout_stream(ctx)) .await .map_err(ToolError::Codex)?; Ok(out) diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 5b18476bfc..3f03622596 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -6,6 +6,7 @@ the session manager to spawn PTYs once an ExecEnv is prepared. */ use crate::error::CodexErr; use crate::error::SandboxErr; +use crate::exec::ExecExpiration; use crate::tools::runtimes::build_command_spec; use crate::tools::sandboxing::Approvable; use crate::tools::sandboxing::ApprovalCtx; @@ -150,13 +151,13 @@ impl<'a> ToolRuntime for UnifiedExecRunt &req.command, &req.cwd, &req.env, - None, + ExecExpiration::DefaultTimeout, req.with_escalated_permissions, req.justification.clone(), ) .map_err(|_| ToolError::Rejected("missing command line for PTY".to_string()))?; let exec_env = attempt - .env_for(&spec) + .env_for(spec) .map_err(|err| ToolError::Codex(err.into()))?; self.manager .open_session_with_exec_env(&exec_env) diff --git a/codex-rs/core/src/tools/sandboxing.rs b/codex-rs/core/src/tools/sandboxing.rs index 23dc8cd3a2..76d3546c49 100644 --- a/codex-rs/core/src/tools/sandboxing.rs +++ b/codex-rs/core/src/tools/sandboxing.rs @@ -216,7 +216,7 @@ pub(crate) struct SandboxAttempt<'a> { impl<'a> SandboxAttempt<'a> { pub fn env_for( &self, - spec: &CommandSpec, + spec: CommandSpec, ) -> Result { self.manager.transform( spec, diff --git a/codex-rs/core/tests/common/Cargo.toml b/codex-rs/core/tests/common/Cargo.toml index 65abe23c63..094f33a26b 100644 --- a/codex-rs/core/tests/common/Cargo.toml +++ b/codex-rs/core/tests/common/Cargo.toml @@ -18,3 +18,4 @@ tempfile = { workspace = true } tokio = { workspace = true, features = ["time"] } walkdir = { workspace = true } wiremock = { workspace = true } +shlex = { workspace = true } diff --git a/codex-rs/core/tests/common/lib.rs b/codex-rs/core/tests/common/lib.rs index 3f75ed1819..e7b1e71efa 100644 --- a/codex-rs/core/tests/common/lib.rs +++ b/codex-rs/core/tests/common/lib.rs @@ -172,6 +172,15 @@ pub fn sandbox_network_env_var() -> &'static str { codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR } +pub fn format_with_current_shell(command: &str) -> Vec { + codex_core::shell::default_user_shell().derive_exec_args(command, true) +} + +pub fn format_with_current_shell_display(command: &str) -> String { + let args = format_with_current_shell(command); + shlex::try_join(args.iter().map(String::as_str)).expect("serialize current shell command") +} + pub mod fs_wait { use anyhow::Result; use anyhow::anyhow; diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 7c5a103f42..b84e966399 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -462,8 +462,11 @@ pub fn ev_apply_patch_function_call(call_id: &str, patch: &str) -> Value { pub fn ev_shell_command_call(call_id: &str, command: &str) -> Value { let args = serde_json::json!({ "command": command }); - let arguments = serde_json::to_string(&args).expect("serialize shell arguments"); + ev_shell_command_call_with_args(call_id, &args) +} +pub fn ev_shell_command_call_with_args(call_id: &str, args: &serde_json::Value) -> Value { + let arguments = serde_json::to_string(args).expect("serialize shell command arguments"); ev_function_call(call_id, "shell_command", &arguments) } diff --git a/codex-rs/core/tests/suite/abort_tasks.rs b/codex-rs/core/tests/suite/abort_tasks.rs index 1774619843..0d4a807a3c 100644 --- a/codex-rs/core/tests/suite/abort_tasks.rs +++ b/codex-rs/core/tests/suite/abort_tasks.rs @@ -17,15 +17,11 @@ use core_test_support::wait_for_event; use regex_lite::Regex; use serde_json::json; -/// Integration test: spawn a long‑running shell tool via a mocked Responses SSE +/// Integration test: spawn a long‑running shell_command tool via a mocked Responses SSE /// function call, then interrupt the session and expect TurnAborted. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn interrupt_long_running_tool_emits_turn_aborted() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "sleep 60".to_string(), - ]; + let command = "sleep 60"; let args = json!({ "command": command, @@ -33,14 +29,19 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { }) .to_string(); let body = sse(vec![ - ev_function_call("call_sleep", "shell", &args), + ev_function_call("call_sleep", "shell_command", &args), ev_completed("done"), ]); let server = start_mock_server().await; mount_sse_once(&server, body).await; - let codex = test_codex().build(&server).await.unwrap().codex; + let codex = test_codex() + .with_model("gpt-5.1") + .build(&server) + .await + .unwrap() + .codex; // Kick off a turn that triggers the function call. codex @@ -67,11 +68,7 @@ async fn interrupt_long_running_tool_emits_turn_aborted() { /// responses server, and ensures the model receives the synthesized abort. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn interrupt_tool_records_history_entries() { - let command = vec![ - "bash".to_string(), - "-lc".to_string(), - "sleep 60".to_string(), - ]; + let command = "sleep 60"; let call_id = "call-history"; let args = json!({ @@ -81,7 +78,7 @@ async fn interrupt_tool_records_history_entries() { .to_string(); let first_body = sse(vec![ ev_response_created("resp-history"), - ev_function_call(call_id, "shell", &args), + ev_function_call(call_id, "shell_command", &args), ev_completed("resp-history"), ]); let follow_up_body = sse(vec![ @@ -92,7 +89,11 @@ async fn interrupt_tool_records_history_entries() { let server = start_mock_server().await; let response_mock = mount_sse_sequence(&server, vec![first_body, follow_up_body]).await; - let fixture = test_codex().build(&server).await.unwrap(); + let fixture = test_codex() + .with_model("gpt-5.1") + .build(&server) + .await + .unwrap(); let codex = Arc::clone(&fixture.codex); codex diff --git a/codex-rs/core/tests/suite/apply_patch_cli.rs b/codex-rs/core/tests/suite/apply_patch_cli.rs index 59ecf421e6..596db602c5 100644 --- a/codex-rs/core/tests/suite/apply_patch_cli.rs +++ b/codex-rs/core/tests/suite/apply_patch_cli.rs @@ -667,7 +667,7 @@ async fn apply_patch_cli_verification_failure_has_no_side_effects( } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apply_patch_shell_heredoc_with_cd_updates_relative_workdir() -> Result<()> { +async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness_with(|config| { @@ -684,14 +684,11 @@ async fn apply_patch_shell_heredoc_with_cd_updates_relative_workdir() -> Result< let script = "cd sub && apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: in_sub.txt\n@@\n-before\n+after\n*** End Patch\nEOF\n"; let call_id = "shell-heredoc-cd"; - let args = json!({ - "command": ["bash", "-lc", script], - "timeout_ms": 5_000, - }); + let args = json!({ "command": script, "timeout_ms": 5_000 }); let bodies = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -706,14 +703,14 @@ async fn apply_patch_shell_heredoc_with_cd_updates_relative_workdir() -> Result< let out = harness.function_call_stdout(call_id).await; assert!( out.contains("Success."), - "expected successful apply_patch invocation via shell: {out}" + "expected successful apply_patch invocation via shell_command: {out}" ); assert_eq!(fs::read_to_string(&target)?, "after\n"); Ok(()) } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn apply_patch_shell_failure_propagates_error_and_skips_diff() -> Result<()> { +async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() -> Result<()> { skip_if_no_network!(Ok(())); let harness = apply_patch_harness_with(|config| { @@ -730,14 +727,11 @@ async fn apply_patch_shell_failure_propagates_error_and_skips_diff() -> Result<( let script = "apply_patch <<'EOF'\n*** Begin Patch\n*** Update File: invalid.txt\n@@\n-nope\n+changed\n*** End Patch\nEOF\n"; let call_id = "shell-apply-failure"; - let args = json!({ - "command": ["bash", "-lc", script], - "timeout_ms": 5_000, - }); + let args = json!({ "command": script, "timeout_ms": 5_000 }); let bodies = vec![ sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), sse(vec![ @@ -780,10 +774,6 @@ async fn apply_patch_shell_failure_propagates_error_and_skips_diff() -> Result<( ); let out = harness.function_call_stdout(call_id).await; - assert!( - out.contains("apply_patch verification failed"), - "expected verification failure message" - ); assert!( out.contains("Failed to find expected lines in"), "expected failure diagnostics: {out}" diff --git a/codex-rs/core/tests/suite/approvals.rs b/codex-rs/core/tests/suite/approvals.rs index a106d2eae0..3eacdc3350 100644 --- a/codex-rs/core/tests/suite/approvals.rs +++ b/codex-rs/core/tests/suite/approvals.rs @@ -71,7 +71,7 @@ enum ActionKind { response_body: &'static str, }, RunCommand { - command: &'static [&'static str], + command: &'static str, }, RunUnifiedExecCommand { command: &'static str, @@ -97,20 +97,12 @@ impl ActionKind { server: &MockServer, call_id: &str, with_escalated_permissions: bool, - ) -> Result<(Value, Option>)> { + ) -> Result<(Value, Option)> { match self { ActionKind::WriteFile { target, content } => { let (path, _) = target.resolve_for_patch(test); let _ = fs::remove_file(&path); - let command = vec![ - "/bin/sh".to_string(), - "-c".to_string(), - format!( - "printf {content:?} > {path:?} && cat {path:?}", - content = content, - path = path - ), - ]; + let command = format!("printf {content:?} > {path:?} && cat {path:?}"); let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; Ok((event, Some(command))) } @@ -127,21 +119,18 @@ impl ActionKind { .await; let url = format!("{}{}", server.uri(), endpoint); + let escaped_url = url.replace('\'', "\\'"); let script = format!( - "import sys\nimport urllib.request\nurl = {url:?}\ntry:\n data = urllib.request.urlopen(url, timeout=2).read().decode()\n print('OK:' + data.strip())\nexcept Exception as exc:\n print('ERR:' + exc.__class__.__name__)\n sys.exit(1)", + "import sys\nimport urllib.request\nurl = '{escaped_url}'\ntry:\n data = urllib.request.urlopen(url, timeout=2).read().decode()\n print('OK:' + data.strip())\nexcept Exception as exc:\n print('ERR:' + exc.__class__.__name__)\n sys.exit(1)", ); - let command = vec!["python3".to_string(), "-c".to_string(), script]; + let command = format!("python3 -c \"{script}\""); let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; Ok((event, Some(command))) } ActionKind::RunCommand { command } => { - let command: Vec = command - .iter() - .map(std::string::ToString::to_string) - .collect(); - let event = shell_event(call_id, &command, 1_000, with_escalated_permissions)?; - Ok((event, Some(command))) + let event = shell_event(call_id, command, 1_000, with_escalated_permissions)?; + Ok((event, Some(command.to_string()))) } ActionKind::RunUnifiedExecCommand { command, @@ -154,14 +143,7 @@ impl ActionKind { with_escalated_permissions, *justification, )?; - Ok(( - event, - Some(vec![ - "/bin/bash".to_string(), - "-lc".to_string(), - command.to_string(), - ]), - )) + Ok((event, Some(command.to_string()))) } ActionKind::ApplyPatchFunction { target, content } => { let (path, patch_path) = target.resolve_for_patch(test); @@ -185,19 +167,19 @@ fn build_add_file_patch(patch_path: &str, content: &str) -> String { format!("*** Begin Patch\n*** Add File: {patch_path}\n+{content}\n*** End Patch\n") } -fn shell_apply_patch_command(patch: &str) -> Vec { +fn shell_apply_patch_command(patch: &str) -> String { let mut script = String::from("apply_patch <<'PATCH'\n"); script.push_str(patch); if !patch.ends_with('\n') { script.push('\n'); } script.push_str("PATCH\n"); - vec!["bash".to_string(), "-lc".to_string(), script] + script } fn shell_event( call_id: &str, - command: &[String], + command: &str, timeout_ms: u64, with_escalated_permissions: bool, ) -> Result { @@ -209,7 +191,7 @@ fn shell_event( args["with_escalated_permissions"] = json!(true); } let args_str = serde_json::to_string(&args)?; - Ok(ev_function_call(call_id, "shell", &args_str)) + Ok(ev_function_call(call_id, "shell_command", &args_str)) } fn exec_command_event( @@ -296,7 +278,10 @@ impl Expectation { } Expectation::FileCreatedNoExitCode { target, content } => { let (path, _) = target.resolve_for_patch(test); - assert_eq!(result.exit_code, None, "expected no exit code for {path:?}"); + assert!( + result.exit_code.is_none() || result.exit_code == Some(0), + "expected no exit code for {path:?}", + ); assert!( result.stdout.contains(content), "stdout missing {content:?}: {}", @@ -385,8 +370,8 @@ impl Expectation { ); } Expectation::NetworkSuccessNoExitCode { body_contains } => { - assert_eq!( - result.exit_code, None, + assert!( + result.exit_code.is_none() || result.exit_code == Some(0), "expected no exit code for successful network call: {}", result.stdout ); @@ -433,8 +418,8 @@ impl Expectation { ); } Expectation::CommandSuccessNoExitCode { stdout_contains } => { - assert_eq!( - result.exit_code, None, + assert!( + result.exit_code.is_none() || result.exit_code == Some(0), "expected no exit code for trusted command: {}", result.stdout ); @@ -531,10 +516,18 @@ fn parse_result(item: &Value) -> CommandResult { CommandResult { exit_code, stdout } } Err(_) => { + let structured = Regex::new(r"(?s)^Exit code:\s*(-?\d+).*?Output:\n(.*)$").unwrap(); let regex = Regex::new(r"(?s)^.*?Process exited with code (\d+)\n.*?Output:\n(.*)$").unwrap(); // parse freeform output - if let Some(captures) = regex.captures(output_str) { + if let Some(captures) = structured.captures(output_str) { + let exit_code = captures.get(1).unwrap().as_str().parse::().unwrap(); + let output = captures.get(2).unwrap().as_str(); + CommandResult { + exit_code: Some(exit_code), + stdout: output.to_string(), + } + } else if let Some(captures) = regex.captures(output_str) { let exit_code = captures.get(1).unwrap().as_str().parse::().unwrap(); let output = captures.get(2).unwrap().as_str(); CommandResult { @@ -553,7 +546,7 @@ fn parse_result(item: &Value) -> CommandResult { async fn expect_exec_approval( test: &TestCodex, - expected_command: &[String], + expected_command: &str, ) -> ExecApprovalRequestEvent { let event = wait_for_event(&test.codex, |event| { matches!( @@ -565,7 +558,12 @@ async fn expect_exec_approval( match event { EventMsg::ExecApprovalRequest(approval) => { - assert_eq!(approval.command, expected_command); + let last_arg = approval + .command + .last() + .map(std::string::String::as_str) + .unwrap_or_default(); + assert_eq!(last_arg, expected_command); approval } EventMsg::TaskComplete(_) => panic!("expected approval request before completion"), @@ -660,7 +658,7 @@ fn scenarios() -> Vec { features: vec![], model_override: Some("gpt-5.1"), outcome: Outcome::Auto, - expectation: Expectation::FileCreatedNoExitCode { + expectation: Expectation::FileCreated { target: TargetPath::OutsideWorkspace("dfa_on_request_5_1.txt"), content: "danger-on-request", }, @@ -702,7 +700,7 @@ fn scenarios() -> Vec { approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunCommand { - command: &["echo", "trusted-unless"], + command: "echo trusted-unless", }, with_escalated_permissions: false, features: vec![], @@ -717,7 +715,7 @@ fn scenarios() -> Vec { approval_policy: UnlessTrusted, sandbox_policy: SandboxPolicy::DangerFullAccess, action: ActionKind::RunCommand { - command: &["echo", "trusted-unless"], + command: "echo trusted-unless", }, with_escalated_permissions: false, features: vec![], @@ -880,7 +878,7 @@ fn scenarios() -> Vec { approval_policy: OnRequest, sandbox_policy: SandboxPolicy::ReadOnly, action: ActionKind::RunCommand { - command: &["echo", "trusted-read-only"], + command: "echo trusted-read-only", }, with_escalated_permissions: false, features: vec![], @@ -895,7 +893,7 @@ fn scenarios() -> Vec { approval_policy: OnRequest, sandbox_policy: SandboxPolicy::ReadOnly, action: ActionKind::RunCommand { - command: &["echo", "trusted-read-only"], + command: "echo trusted-read-only", }, with_escalated_permissions: false, features: vec![], @@ -1020,7 +1018,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "apply_patch_shell_requires_patch_approval", + name: "apply_patch_shell_command_requires_patch_approval", approval_policy: UnlessTrusted, sandbox_policy: workspace_write(false), action: ActionKind::ApplyPatchShell { @@ -1114,7 +1112,7 @@ fn scenarios() -> Vec { }, }, ScenarioSpec { - name: "apply_patch_shell_outside_requires_patch_approval", + name: "apply_patch_shell_command_outside_requires_patch_approval", approval_policy: OnRequest, sandbox_policy: workspace_write(false), action: ActionKind::ApplyPatchShell { @@ -1229,7 +1227,10 @@ fn scenarios() -> Vec { message_contains: if cfg!(target_os = "linux") { &["Permission denied"] } else { - &["Permission denied|Operation not permitted|Read-only file system"] + &[ + "Permission denied|Operation not permitted|operation not permitted|\ + Read-only file system", + ] }, }, }, @@ -1238,7 +1239,7 @@ fn scenarios() -> Vec { approval_policy: Never, sandbox_policy: SandboxPolicy::ReadOnly, action: ActionKind::RunCommand { - command: &["echo", "trusted-never"], + command: "echo trusted-never", }, with_escalated_permissions: false, features: vec![], @@ -1373,7 +1374,10 @@ fn scenarios() -> Vec { message_contains: if cfg!(target_os = "linux") { &["Permission denied"] } else { - &["Permission denied|Operation not permitted|Read-only file system"] + &[ + "Permission denied|Operation not permitted|operation not permitted|\ + Read-only file system", + ] }, }, }, @@ -1509,7 +1513,7 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> { expected_reason, } => { let command = expected_command - .as_ref() + .as_deref() .expect("exec approval requires shell command"); let approval = expect_exec_approval(&test, command).await; if let Some(expected_reason) = expected_reason { diff --git a/codex-rs/core/tests/suite/cli_stream.rs b/codex-rs/core/tests/suite/cli_stream.rs index b484eca22a..d7f0fb9830 100644 --- a/codex-rs/core/tests/suite/cli_stream.rs +++ b/codex-rs/core/tests/suite/cli_stream.rs @@ -499,9 +499,20 @@ async fn integration_git_info_unit_test() { "Git info should contain repository_url" ); let repo_url = git_info.repository_url.as_ref().unwrap(); + // Some hosts rewrite remotes (e.g., github.com → git@github.com), so assert against + // the actual remote reported by git instead of a static URL. + let expected_remote_url = std::process::Command::new("git") + .args(["remote", "get-url", "origin"]) + .current_dir(&git_repo) + .output() + .unwrap(); + let expected_remote_url = String::from_utf8(expected_remote_url.stdout) + .unwrap() + .trim() + .to_string(); assert_eq!( - repo_url, "https://github.com/example/integration-test.git", - "Repository URL should match what we configured" + repo_url, &expected_remote_url, + "Repository URL should match git remote get-url output" ); println!("✅ Git info collection test passed!"); diff --git a/codex-rs/core/tests/suite/client.rs b/codex-rs/core/tests/suite/client.rs index 59b7e34171..71bcd5192d 100644 --- a/codex-rs/core/tests/suite/client.rs +++ b/codex-rs/core/tests/suite/client.rs @@ -992,7 +992,7 @@ async fn azure_responses_request_includes_store_and_reasoning_ids() { id: Some("web-search-id".into()), status: Some("completed".into()), action: WebSearchAction::Search { - query: "weather".into(), + query: Some("weather".into()), }, }); prompt.input.push(ResponseItem::FunctionCall { diff --git a/codex-rs/core/tests/suite/codex_delegate.rs b/codex-rs/core/tests/suite/codex_delegate.rs index 6339bfa71a..36cf0b865f 100644 --- a/codex-rs/core/tests/suite/codex_delegate.rs +++ b/codex-rs/core/tests/suite/codex_delegate.rs @@ -1,3 +1,4 @@ +use codex_core::model_family::find_family_for_model; use codex_core::protocol::AskForApproval; use codex_core::protocol::EventMsg; use codex_core::protocol::Op; @@ -25,17 +26,17 @@ use pretty_assertions::assert_eq; async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { skip_if_no_network!(); - // Sub-agent turn 1: emit a shell function_call requiring approval, then complete. + // Sub-agent turn 1: emit a shell_command function_call requiring approval, then complete. let call_id = "call-exec-1"; let args = serde_json::json!({ - "command": ["bash", "-lc", "rm -rf delegated"], + "command": "rm -rf delegated", "timeout_ms": 1000, "with_escalated_permissions": true, }) .to_string(); let sse1 = sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &args), + ev_function_call(call_id, "shell_command", &args), ev_completed("resp-1"), ]); @@ -61,6 +62,8 @@ async fn codex_delegate_forwards_exec_approval_and_proceeds_on_approval() { let mut builder = test_codex().with_config(|config| { config.approval_policy = AskForApproval::OnRequest; config.sandbox_policy = SandboxPolicy::ReadOnly; + config.model = "gpt-5.1".to_string(); + config.model_family = find_family_for_model("gpt-5.1").expect("gpt-5.1 is a valid model"); }); let test = builder.build(&server).await.expect("build test codex"); @@ -138,6 +141,8 @@ async fn codex_delegate_forwards_patch_approval_and_proceeds_on_decision() { // Use a restricted sandbox so patch approval is required config.sandbox_policy = SandboxPolicy::ReadOnly; config.include_apply_patch_tool = true; + config.model = "gpt-5.1".to_string(); + config.model_family = find_family_for_model("gpt-5.1").expect("gpt-5.1 is a valid model"); }); let test = builder.build(&server).await.expect("build test codex"); diff --git a/codex-rs/core/tests/suite/exec.rs b/codex-rs/core/tests/suite/exec.rs index f991a96170..932c2c9b8a 100644 --- a/codex-rs/core/tests/suite/exec.rs +++ b/codex-rs/core/tests/suite/exec.rs @@ -25,7 +25,7 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result Result<()> { + // TODO execpolicy doesn't parse powershell commands yet + if cfg!(windows) { + return Ok(()); + } + let mut builder = test_codex().with_config(|config| { let policy_path = config.codex_home.join("policy").join("policy.codexpolicy"); fs::create_dir_all( @@ -34,13 +40,16 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { r#"prefix_rule(pattern=["echo"], decision="forbidden")"#, ) .expect("write policy file"); + config.model = "gpt-5.1".to_string(); + config.model_family = + find_family_for_model("gpt-5.1").expect("gpt-5.1 should have a model family"); }); let server = start_mock_server().await; let test = builder.build(&server).await?; let call_id = "shell-forbidden"; let args = json!({ - "command": ["echo", "blocked"], + "command": "echo blocked", "timeout_ms": 1_000, }); @@ -48,7 +57,7 @@ async fn execpolicy_blocks_shell_invocation() -> Result<()> { &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) diff --git a/codex-rs/core/tests/suite/tool_parallelism.rs b/codex-rs/core/tests/suite/tool_parallelism.rs index 807bb9d82c..96998f5d15 100644 --- a/codex-rs/core/tests/suite/tool_parallelism.rs +++ b/codex-rs/core/tests/suite/tool_parallelism.rs @@ -146,10 +146,11 @@ async fn non_parallel_tools_run_serially() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; - let test = test_codex().build(&server).await?; + let mut builder = test_codex().with_model("gpt-5.1"); + let test = builder.build(&server).await?; let shell_args = json!({ - "command": ["/bin/sh", "-c", "sleep 0.3"], + "command": "sleep 0.3", "timeout_ms": 1_000, }); let args_one = serde_json::to_string(&shell_args)?; @@ -157,8 +158,8 @@ async fn non_parallel_tools_run_serially() -> anyhow::Result<()> { let first_response = sse(vec![ json!({"type": "response.created", "response": {"id": "resp-1"}}), - ev_function_call("call-1", "shell", &args_one), - ev_function_call("call-2", "shell", &args_two), + ev_function_call("call-1", "shell_command", &args_one), + ev_function_call("call-2", "shell_command", &args_two), ev_completed("resp-1"), ]); let second_response = sse(vec![ @@ -167,7 +168,7 @@ async fn non_parallel_tools_run_serially() -> anyhow::Result<()> { ]); mount_sse_sequence(&server, vec![first_response, second_response]).await; - let duration = run_turn_and_measure(&test, "run shell twice").await?; + let duration = run_turn_and_measure(&test, "run shell_command twice").await?; assert_serial_duration(duration); Ok(()) @@ -185,14 +186,14 @@ async fn mixed_tools_fall_back_to_serial() -> anyhow::Result<()> { }) .to_string(); let shell_args = serde_json::to_string(&json!({ - "command": ["/bin/sh", "-c", "sleep 0.3"], + "command": "sleep 0.3", "timeout_ms": 1_000, }))?; let first_response = sse(vec![ json!({"type": "response.created", "response": {"id": "resp-1"}}), ev_function_call("call-1", "test_sync_tool", &sync_args), - ev_function_call("call-2", "shell", &shell_args), + ev_function_call("call-2", "shell_command", &shell_args), ev_completed("resp-1"), ]); let second_response = sse(vec![ @@ -215,7 +216,7 @@ async fn tool_results_grouped() -> anyhow::Result<()> { let test = build_codex_with_test_tool(&server).await?; let shell_args = serde_json::to_string(&json!({ - "command": ["/bin/sh", "-c", "echo 'shell output'"], + "command": "echo 'shell output'", "timeout_ms": 1_000, }))?; @@ -223,9 +224,9 @@ async fn tool_results_grouped() -> anyhow::Result<()> { &server, sse(vec![ json!({"type": "response.created", "response": {"id": "resp-1"}}), - ev_function_call("call-1", "shell", &shell_args), - ev_function_call("call-2", "shell", &shell_args), - ev_function_call("call-3", "shell", &shell_args), + ev_function_call("call-1", "shell_command", &shell_args), + ev_function_call("call-2", "shell_command", &shell_args), + ev_function_call("call-3", "shell_command", &shell_args), ev_completed("resp-1"), ]), ) diff --git a/codex-rs/core/tests/suite/truncation.rs b/codex-rs/core/tests/suite/truncation.rs index ed9e7cde3b..f06694f628 100644 --- a/codex-rs/core/tests/suite/truncation.rs +++ b/codex-rs/core/tests/suite/truncation.rs @@ -98,7 +98,7 @@ async fn truncate_function_error_trims_respond_to_model() -> Result<()> { Ok(()) } -// Verifies that a standard tool call (shell) exceeding the model formatting +// Verifies that a standard tool call (shell_command) exceeding the model formatting // limits is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_configured_limit_chars_type() -> Result<()> { @@ -106,7 +106,7 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> { let server = start_mock_server().await; - // Use a model that exposes the generic shell tool. + // Use a model that exposes the shell_command tool. let mut builder = test_codex().with_model("gpt-5.1").with_config(|config| { config.tool_output_token_limit = Some(100_000); }); @@ -114,28 +114,22 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> { let fixture = builder.build(&server).await?; let call_id = "shell-too-large"; - let args = if cfg!(windows) { - serde_json::json!({ - "command": [ - "powershell", - "-Command", - "for ($i=1; $i -le 100000; $i++) { Write-Output $i }" - ], - "timeout_ms": 5_000, - }) + let command = if cfg!(windows) { + "for ($i=1; $i -le 100000; $i++) { Write-Output $i }" } else { - serde_json::json!({ - "command": ["/bin/sh", "-c", "seq 1 100000"], - "timeout_ms": 5_000, - }) + "seq 1 100000" }; + let args = serde_json::json!({ + "command": command, + "timeout_ms": 5_000, + }); // First response: model tells us to run the tool; second: complete the turn. mount_sse_once( &server, sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) @@ -167,7 +161,10 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> { "expected truncated shell output to be plain text" ); - assert_eq!(output.len(), 400097, "we should be almost 100k tokens"); + assert!( + (400000..=401000).contains(&output.len()), + "we should be almost 100k tokens" + ); assert!( !output.contains("tokens truncated"), @@ -177,7 +174,7 @@ async fn tool_call_output_configured_limit_chars_type() -> Result<()> { Ok(()) } -// Verifies that a standard tool call (shell) exceeding the model formatting +// Verifies that a standard tool call (shell_command) exceeding the model formatting // limits is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { @@ -185,34 +182,28 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { let server = start_mock_server().await; - // Use a model that exposes the generic shell tool. + // Use a model that exposes the shell_command tool. let mut builder = test_codex().with_model("gpt-5.1"); let fixture = builder.build(&server).await?; let call_id = "shell-too-large"; - let args = if cfg!(windows) { - serde_json::json!({ - "command": [ - "powershell", - "-Command", - "for ($i=1; $i -le 100000; $i++) { Write-Output $i }" - ], - "timeout_ms": 5_000, - }) + let command = if cfg!(windows) { + "for ($i=1; $i -le 100000; $i++) { Write-Output $i }" } else { - serde_json::json!({ - "command": ["/bin/sh", "-c", "seq 1 100000"], - "timeout_ms": 5_000, - }) + "seq 1 100000" }; + let args = serde_json::json!({ + "command": command, + "timeout_ms": 5_000, + }); // First response: model tells us to run the tool; second: complete the turn. mount_sse_once( &server, sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) @@ -244,15 +235,20 @@ async fn tool_call_output_exceeds_limit_truncated_chars_limit() -> Result<()> { "expected truncated shell output to be plain text" ); - assert_eq!(output.len(), 9976); // ~10k characters - let truncated_pattern = r#"(?s)^Exit code: 0\nWall time: 0 seconds\nTotal output lines: 100000\nOutput:\n.*?…\d+ chars truncated….*$"#; + let truncated_pattern = r#"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nTotal output lines: 100000\nOutput:\n.*?…\d+ chars truncated….*$"#; assert_regex_match(truncated_pattern, &output); + let len = output.len(); + assert!( + (9_900..=10_100).contains(&len), + "expected ~10k chars after truncation, got {len}" + ); + Ok(()) } -// Verifies that a standard tool call (shell) exceeding the model formatting +// Verifies that a standard tool call (shell_command) exceeding the model formatting // limits is truncated before being sent back to the model. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> { @@ -260,7 +256,7 @@ async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> { let server = start_mock_server().await; - // Use a model that exposes the generic shell tool. + // Use a model that exposes the shell_command tool. let mut builder = test_codex().with_config(|config| { config.model = "gpt-5.1-codex".to_string(); config.model_family = @@ -269,28 +265,22 @@ async fn tool_call_output_exceeds_limit_truncated_for_model() -> Result<()> { let fixture = builder.build(&server).await?; let call_id = "shell-too-large"; - let args = if cfg!(windows) { - serde_json::json!({ - "command": [ - "powershell", - "-Command", - "for ($i=1; $i -le 100000; $i++) { Write-Output $i }" - ], - "timeout_ms": 5_000, - }) + let command = if cfg!(windows) { + "for ($i=1; $i -le 100000; $i++) { Write-Output $i }" } else { - serde_json::json!({ - "command": ["/bin/sh", "-c", "seq 1 100000"], - "timeout_ms": 5_000, - }) + "seq 1 100000" }; + let args = serde_json::json!({ + "command": command, + "timeout_ms": 5_000, + }); // First response: model tells us to run the tool; second: complete the turn. mount_sse_once( &server, sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) @@ -340,7 +330,7 @@ $"#; Ok(()) } -// Ensures shell tool outputs that exceed the line limit are truncated only once. +// Ensures shell_command outputs that exceed the line limit are truncated only once. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn tool_call_output_truncated_only_once() -> Result<()> { skip_if_no_network!(Ok(())); @@ -354,27 +344,21 @@ async fn tool_call_output_truncated_only_once() -> Result<()> { }); let fixture = builder.build(&server).await?; let call_id = "shell-single-truncation"; - let args = if cfg!(windows) { - serde_json::json!({ - "command": [ - "powershell", - "-Command", - "for ($i=1; $i -le 10000; $i++) { Write-Output $i }" - ], - "timeout_ms": 5_000, - }) + let command = if cfg!(windows) { + "for ($i=1; $i -le 10000; $i++) { Write-Output $i }" } else { - serde_json::json!({ - "command": ["/bin/sh", "-c", "seq 1 10000"], - "timeout_ms": 5_000, - }) + "seq 1 10000" }; + let args = serde_json::json!({ + "command": command, + "timeout_ms": 5_000, + }); mount_sse_once( &server, sse(vec![ responses::ev_response_created("resp-1"), - responses::ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + responses::ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), responses::ev_completed("resp-1"), ]), ) @@ -614,7 +598,7 @@ async fn token_policy_marker_reports_tokens() -> Result<()> { let call_id = "shell-token-marker"; let args = json!({ - "command": ["/bin/sh", "-c", "seq 1 150"], + "command": "seq 1 150", "timeout_ms": 5_000, }); @@ -622,7 +606,7 @@ async fn token_policy_marker_reports_tokens() -> Result<()> { &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) @@ -645,7 +629,7 @@ async fn token_policy_marker_reports_tokens() -> Result<()> { .function_call_output_text(call_id) .context("shell output present")?; - let pattern = r#"(?s)^\{"output":"Total output lines: 150\\n\\n1\\n2\\n3\\n4\\n5\\n.*?…\d+ tokens truncated…7\\n138\\n139\\n140\\n141\\n142\\n143\\n144\\n145\\n146\\n147\\n148\\n149\\n150\\n","metadata":\{"exit_code":0,"duration_seconds":0\.0\}\}$"#; + let pattern = r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nTotal output lines: 150\nOutput:\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19.*tokens truncated.*129\n130\n131\n132\n133\n134\n135\n136\n137\n138\n139\n140\n141\n142\n143\n144\n145\n146\n147\n148\n149\n150\n$"; assert_regex_match(pattern, &output); @@ -667,7 +651,7 @@ async fn byte_policy_marker_reports_bytes() -> Result<()> { let call_id = "shell-byte-marker"; let args = json!({ - "command": ["/bin/sh", "-c", "seq 1 150"], + "command": "seq 1 150", "timeout_ms": 5_000, }); @@ -675,7 +659,7 @@ async fn byte_policy_marker_reports_bytes() -> Result<()> { &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) @@ -698,16 +682,16 @@ async fn byte_policy_marker_reports_bytes() -> Result<()> { .function_call_output_text(call_id) .context("shell output present")?; - let pattern = r#"(?s)^\{"output":"Total output lines: 150\\n\\n1\\n2\\n3\\n4\\n5.*?…\d+ chars truncated…7\\n138\\n139\\n140\\n141\\n142\\n143\\n144\\n145\\n146\\n147\\n148\\n149\\n150\\n","metadata":\{"exit_code":0,"duration_seconds":0\.0\}\}$"#; + let pattern = r"(?s)^Exit code: 0\nWall time: [0-9]+(?:\.[0-9]+)? seconds\nTotal output lines: 150\nOutput:\n1\n2\n3\n4\n5\n6\n7\n8\n9\n10\n11\n12\n13\n14\n15\n16\n17\n18\n19.*chars truncated.*129\n130\n131\n132\n133\n134\n135\n136\n137\n138\n139\n140\n141\n142\n143\n144\n145\n146\n147\n148\n149\n150\n$"; assert_regex_match(pattern, &output); Ok(()) } -// Shell tool output should remain intact when the config opts into a large token budget. +// shell_command output should remain intact when the config opts into a large token budget. #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn shell_tool_output_not_truncated_with_custom_limit() -> Result<()> { +async fn shell_command_output_not_truncated_with_custom_limit() -> Result<()> { skip_if_no_network!(Ok(())); let server = start_mock_server().await; @@ -721,7 +705,7 @@ async fn shell_tool_output_not_truncated_with_custom_limit() -> Result<()> { let call_id = "shell-no-trunc"; let args = json!({ - "command": ["/bin/sh", "-c", "seq 1 1000"], + "command": "seq 1 1000", "timeout_ms": 5_000, }); let expected_body: String = (1..=1000).map(|i| format!("{i}\n")).collect(); @@ -730,7 +714,7 @@ async fn shell_tool_output_not_truncated_with_custom_limit() -> Result<()> { &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) diff --git a/codex-rs/core/tests/suite/user_shell_cmd.rs b/codex-rs/core/tests/suite/user_shell_cmd.rs index e6fbdee9e5..f6e53b95e3 100644 --- a/codex-rs/core/tests/suite/user_shell_cmd.rs +++ b/codex-rs/core/tests/suite/user_shell_cmd.rs @@ -279,23 +279,19 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> { config.tool_output_token_limit = Some(100); config.model = "gpt-5.1-codex".to_string(); config.model_family = - find_family_for_model("gpt-5-codex").expect("gpt-5-codex is a model family"); + find_family_for_model("gpt-5.1-codex").expect("gpt-5.1-codex is a model family"); }); let fixture = builder.build(&server).await?; let call_id = "user-shell-double-truncation"; let args = if cfg!(windows) { serde_json::json!({ - "command": [ - "powershell", - "-Command", - "for ($i=1; $i -le 2000; $i++) { Write-Output $i }" - ], + "command": "for ($i=1; $i -le 2000; $i++) { Write-Output $i }", "timeout_ms": 5_000, }) } else { serde_json::json!({ - "command": ["/bin/sh", "-c", "seq 1 2000"], + "command": "seq 1 2000", "timeout_ms": 5_000, }) }; @@ -304,7 +300,7 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> { &server, sse(vec![ ev_response_created("resp-1"), - ev_function_call(call_id, "shell", &serde_json::to_string(&args)?), + ev_function_call(call_id, "shell_command", &serde_json::to_string(&args)?), ev_completed("resp-1"), ]), ) @@ -319,19 +315,22 @@ async fn user_shell_command_is_truncated_only_once() -> anyhow::Result<()> { .await; fixture - .submit_turn_with_policy("trigger big shell output", SandboxPolicy::DangerFullAccess) + .submit_turn_with_policy( + "trigger big shell_command output", + SandboxPolicy::DangerFullAccess, + ) .await?; let output = mock2 .single_request() .function_call_output_text(call_id) - .context("function_call_output present for shell call")?; + .context("function_call_output present for shell_command call")?; let truncation_headers = output.matches("Total output lines:").count(); assert_eq!( truncation_headers, 1, - "shell output should carry only one truncation header: {output}" + "shell_command output should carry only one truncation header: {output}" ); Ok(()) diff --git a/codex-rs/exec-server/Cargo.toml b/codex-rs/exec-server/Cargo.toml index 54cead4118..24c13e0e25 100644 --- a/codex-rs/exec-server/Cargo.toml +++ b/codex-rs/exec-server/Cargo.toml @@ -49,6 +49,7 @@ tokio = { workspace = true, features = [ "rt-multi-thread", "signal", ] } +tokio-util = { workspace = true } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] } diff --git a/codex-rs/exec-server/src/posix.rs b/codex-rs/exec-server/src/posix.rs index ea0fab1a7e..b4dd0fbf40 100644 --- a/codex-rs/exec-server/src/posix.rs +++ b/codex-rs/exec-server/src/posix.rs @@ -71,6 +71,7 @@ mod escalation_policy; mod mcp; mod mcp_escalation_policy; mod socket; +mod stopwatch; /// Default value of --execve option relative to the current executable. /// Note this must match the name of the binary as specified in Cargo.toml. diff --git a/codex-rs/exec-server/src/posix/escalate_server.rs b/codex-rs/exec-server/src/posix/escalate_server.rs index 3241477f41..59c9533dd3 100644 --- a/codex-rs/exec-server/src/posix/escalate_server.rs +++ b/codex-rs/exec-server/src/posix/escalate_server.rs @@ -11,6 +11,7 @@ use path_absolutize::Absolutize as _; use codex_core::exec::process_exec_tool_call; use codex_core::protocol::SandboxPolicy; 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; @@ -22,6 +23,7 @@ use crate::posix::escalate_protocol::SuperExecResult; use crate::posix::escalation_policy::EscalationPolicy; use crate::posix::socket::AsyncDatagramSocket; use crate::posix::socket::AsyncSocket; +use codex_core::exec::ExecExpiration; pub(crate) struct EscalateServer { bash_path: PathBuf, @@ -46,7 +48,7 @@ impl EscalateServer { command: String, env: HashMap, workdir: PathBuf, - timeout_ms: Option, + cancel_rx: CancellationToken, ) -> anyhow::Result { let (escalate_server, escalate_client) = AsyncDatagramSocket::pair()?; let client_socket = escalate_client.into_inner(); @@ -77,7 +79,7 @@ impl EscalateServer { command, ], cwd: PathBuf::from(&workdir), - timeout_ms, + expiration: ExecExpiration::Cancellation(cancel_rx), env, with_escalated_permissions: None, justification: None, diff --git a/codex-rs/exec-server/src/posix/mcp.rs b/codex-rs/exec-server/src/posix/mcp.rs index f5785dc5d0..b2f9b6de48 100644 --- a/codex-rs/exec-server/src/posix/mcp.rs +++ b/codex-rs/exec-server/src/posix/mcp.rs @@ -22,6 +22,7 @@ use crate::posix::escalate_server::EscalateServer; use crate::posix::escalate_server::{self}; use crate::posix::mcp_escalation_policy::ExecPolicy; use crate::posix::mcp_escalation_policy::McpEscalationPolicy; +use crate::posix::stopwatch::Stopwatch; /// Path to our patched bash. const CODEX_BASH_PATH_ENV_VAR: &str = "CODEX_BASH_PATH"; @@ -87,10 +88,17 @@ impl ExecTool { context: RequestContext, Parameters(params): Parameters, ) -> Result { + let effective_timeout = Duration::from_millis( + params + .timeout_ms + .unwrap_or(codex_core::exec::DEFAULT_EXEC_COMMAND_TIMEOUT_MS), + ); + let stopwatch = Stopwatch::new(effective_timeout); + let cancel_token = stopwatch.cancellation_token(); let escalate_server = EscalateServer::new( self.bash_path.clone(), self.execve_wrapper.clone(), - McpEscalationPolicy::new(self.policy, context), + McpEscalationPolicy::new(self.policy, context, stopwatch.clone()), ); let result = escalate_server .exec( @@ -98,7 +106,7 @@ impl ExecTool { // TODO: use ShellEnvironmentPolicy std::env::vars().collect(), PathBuf::from(¶ms.workdir), - params.timeout_ms, + cancel_token, ) .await .map_err(|e| McpError::internal_error(e.to_string(), None))?; diff --git a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs index 069948ea06..9e059fdba5 100644 --- a/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs +++ b/codex-rs/exec-server/src/posix/mcp_escalation_policy.rs @@ -10,6 +10,7 @@ use rmcp::service::RequestContext; use crate::posix::escalate_protocol::EscalateAction; use crate::posix::escalation_policy::EscalationPolicy; +use crate::posix::stopwatch::Stopwatch; /// This is the policy which decides how to handle an exec() call. /// @@ -34,11 +35,20 @@ pub(crate) enum ExecPolicyOutcome { pub(crate) struct McpEscalationPolicy { policy: ExecPolicy, context: RequestContext, + stopwatch: Stopwatch, } impl McpEscalationPolicy { - pub(crate) fn new(policy: ExecPolicy, context: RequestContext) -> Self { - Self { policy, context } + pub(crate) fn new( + policy: ExecPolicy, + context: RequestContext, + stopwatch: Stopwatch, + ) -> Self { + Self { + policy, + context, + stopwatch, + } } async fn prompt( @@ -54,25 +64,34 @@ impl McpEscalationPolicy { } else { format!("{} {}", file.display(), args) }; - context - .peer - .create_elicitation(CreateElicitationRequestParam { - message: format!("Allow agent to run `{command}` in `{}`?", workdir.display()), - requested_schema: ElicitationSchema::builder() - .title("Execution Permission Request") - .optional_string_with("reason", |schema| { - schema.description("Optional reason for allowing or denying execution") + self.stopwatch + .pause_for(async { + context + .peer + .create_elicitation(CreateElicitationRequestParam { + message: format!( + "Allow agent to run `{command}` in `{}`?", + workdir.display() + ), + requested_schema: ElicitationSchema::builder() + .title("Execution Permission Request") + .optional_string_with("reason", |schema| { + schema.description( + "Optional reason for allowing or denying execution", + ) + }) + .build() + .map_err(|e| { + McpError::internal_error( + format!("failed to build elicitation schema: {e}"), + None, + ) + })?, }) - .build() - .map_err(|e| { - McpError::internal_error( - format!("failed to build elicitation schema: {e}"), - None, - ) - })?, + .await + .map_err(|e| McpError::internal_error(e.to_string(), None)) }) .await - .map_err(|e| McpError::internal_error(e.to_string(), None)) } } diff --git a/codex-rs/exec-server/src/posix/stopwatch.rs b/codex-rs/exec-server/src/posix/stopwatch.rs new file mode 100644 index 0000000000..de29a45685 --- /dev/null +++ b/codex-rs/exec-server/src/posix/stopwatch.rs @@ -0,0 +1,211 @@ +use std::future::Future; +use std::sync::Arc; +use std::time::Duration; +use std::time::Instant; + +use tokio::sync::Mutex; +use tokio::sync::Notify; +use tokio_util::sync::CancellationToken; + +#[derive(Clone, Debug)] +pub(crate) struct Stopwatch { + limit: Duration, + inner: Arc>, + notify: Arc, +} + +#[derive(Debug)] +struct StopwatchState { + elapsed: Duration, + running_since: Option, + active_pauses: u32, +} + +impl Stopwatch { + pub(crate) fn new(limit: Duration) -> Self { + Self { + inner: Arc::new(Mutex::new(StopwatchState { + elapsed: Duration::ZERO, + running_since: Some(Instant::now()), + active_pauses: 0, + })), + notify: Arc::new(Notify::new()), + limit, + } + } + + pub(crate) fn cancellation_token(&self) -> CancellationToken { + let limit = self.limit; + let token = CancellationToken::new(); + let cancel = token.clone(); + let inner = Arc::clone(&self.inner); + let notify = Arc::clone(&self.notify); + tokio::spawn(async move { + loop { + let (remaining, running) = { + let guard = inner.lock().await; + let elapsed = guard.elapsed + + guard + .running_since + .map(|since| since.elapsed()) + .unwrap_or_default(); + if elapsed >= limit { + break; + } + (limit - elapsed, guard.running_since.is_some()) + }; + + if !running { + notify.notified().await; + continue; + } + + let sleep = tokio::time::sleep(remaining); + tokio::pin!(sleep); + tokio::select! { + _ = &mut sleep => { + break; + } + _ = notify.notified() => { + continue; + } + } + } + cancel.cancel(); + }); + token + } + + /// Runs `fut`, pausing the stopwatch while the future is pending. The clock + /// resumes automatically when the future completes. Nested/overlapping + /// calls are reference-counted so the stopwatch only resumes when every + /// pause is lifted. + pub(crate) async fn pause_for(&self, fut: F) -> T + where + F: Future, + { + self.pause().await; + let result = fut.await; + self.resume().await; + result + } + + async fn pause(&self) { + let mut guard = self.inner.lock().await; + guard.active_pauses += 1; + if guard.active_pauses == 1 + && let Some(since) = guard.running_since.take() + { + guard.elapsed += since.elapsed(); + self.notify.notify_waiters(); + } + } + + async fn resume(&self) { + let mut guard = self.inner.lock().await; + if guard.active_pauses == 0 { + return; + } + guard.active_pauses -= 1; + if guard.active_pauses == 0 && guard.running_since.is_none() { + guard.running_since = Some(Instant::now()); + self.notify.notify_waiters(); + } + } +} + +#[cfg(test)] +mod tests { + use super::Stopwatch; + use tokio::time::Duration; + use tokio::time::Instant; + use tokio::time::sleep; + use tokio::time::timeout; + + #[tokio::test] + async fn cancellation_receiver_fires_after_limit() { + let stopwatch = Stopwatch::new(Duration::from_millis(50)); + let token = stopwatch.cancellation_token(); + let start = Instant::now(); + token.cancelled().await; + assert!(start.elapsed() >= Duration::from_millis(50)); + } + + #[tokio::test] + async fn pause_prevents_timeout_until_resumed() { + let stopwatch = Stopwatch::new(Duration::from_millis(50)); + let token = stopwatch.cancellation_token(); + + let pause_handle = tokio::spawn({ + let stopwatch = stopwatch.clone(); + async move { + stopwatch + .pause_for(async { + sleep(Duration::from_millis(100)).await; + }) + .await; + } + }); + + assert!( + timeout(Duration::from_millis(30), token.cancelled()) + .await + .is_err() + ); + + pause_handle.await.expect("pause task should finish"); + + token.cancelled().await; + } + + #[tokio::test] + async fn overlapping_pauses_only_resume_once() { + let stopwatch = Stopwatch::new(Duration::from_millis(50)); + let token = stopwatch.cancellation_token(); + + // First pause. + let pause1 = { + let stopwatch = stopwatch.clone(); + tokio::spawn(async move { + stopwatch + .pause_for(async { + sleep(Duration::from_millis(80)).await; + }) + .await; + }) + }; + + // Overlapping pause that ends sooner. + let pause2 = { + let stopwatch = stopwatch.clone(); + tokio::spawn(async move { + stopwatch + .pause_for(async { + sleep(Duration::from_millis(30)).await; + }) + .await; + }) + }; + + // While both pauses are active, the cancellation should not fire. + assert!( + timeout(Duration::from_millis(40), token.cancelled()) + .await + .is_err() + ); + + pause2.await.expect("short pause should complete"); + + // Still paused because the long pause is active. + assert!( + timeout(Duration::from_millis(30), token.cancelled()) + .await + .is_err() + ); + + pause1.await.expect("long pause should complete"); + + // Now the stopwatch should resume and hit the limit shortly after. + token.cancelled().await; + } +} diff --git a/codex-rs/exec/src/exec_events.rs b/codex-rs/exec/src/exec_events.rs index 23f471e224..f3726dad76 100644 --- a/codex-rs/exec/src/exec_events.rs +++ b/codex-rs/exec/src/exec_events.rs @@ -144,6 +144,7 @@ pub enum CommandExecutionStatus { InProgress, Completed, Failed, + Declined, } /// A command executed by the agent. diff --git a/codex-rs/exec/tests/event_processor_with_json_output.rs b/codex-rs/exec/tests/event_processor_with_json_output.rs index f716f0f0f4..06f99b96b1 100644 --- a/codex-rs/exec/tests/event_processor_with_json_output.rs +++ b/codex-rs/exec/tests/event_processor_with_json_output.rs @@ -540,7 +540,7 @@ fn error_event_produces_error() { "e1", EventMsg::Error(codex_core::protocol::ErrorEvent { message: "boom".to_string(), - codex_error_code: Some(CodexErrorInfo::Other), + codex_error_info: Some(CodexErrorInfo::Other), }), )); assert_eq!( @@ -580,7 +580,7 @@ fn stream_error_event_produces_error() { "e1", EventMsg::StreamError(codex_core::protocol::StreamErrorEvent { message: "retrying".to_string(), - codex_error_code: Some(CodexErrorInfo::Other), + codex_error_info: Some(CodexErrorInfo::Other), }), )); assert_eq!( @@ -599,7 +599,7 @@ fn error_followed_by_task_complete_produces_turn_failed() { "e1", EventMsg::Error(ErrorEvent { message: "boom".to_string(), - codex_error_code: Some(CodexErrorInfo::Other), + codex_error_info: Some(CodexErrorInfo::Other), }), ); assert_eq!( diff --git a/codex-rs/linux-sandbox/tests/suite/landlock.rs b/codex-rs/linux-sandbox/tests/suite/landlock.rs index efe91b41c2..5f7b68e867 100644 --- a/codex-rs/linux-sandbox/tests/suite/landlock.rs +++ b/codex-rs/linux-sandbox/tests/suite/landlock.rs @@ -39,7 +39,7 @@ async fn run_cmd(cmd: &[&str], writable_roots: &[PathBuf], timeout_ms: u64) { let params = ExecParams { command: cmd.iter().copied().map(str::to_owned).collect(), cwd, - timeout_ms: Some(timeout_ms), + expiration: timeout_ms.into(), env: create_env_from_core_vars(), with_escalated_permissions: None, justification: None, @@ -142,7 +142,7 @@ async fn assert_network_blocked(cmd: &[&str]) { cwd, // Give the tool a generous 2-second timeout so even slow DNS timeouts // do not stall the suite. - timeout_ms: Some(NETWORK_TIMEOUT_MS), + expiration: NETWORK_TIMEOUT_MS.into(), env: create_env_from_core_vars(), with_escalated_permissions: None, justification: None, diff --git a/codex-rs/mcp-server/tests/common/Cargo.toml b/codex-rs/mcp-server/tests/common/Cargo.toml index 7c2bc2266a..286cd63c7f 100644 --- a/codex-rs/mcp-server/tests/common/Cargo.toml +++ b/codex-rs/mcp-server/tests/common/Cargo.toml @@ -23,3 +23,5 @@ tokio = { workspace = true, features = [ "rt-multi-thread", ] } wiremock = { workspace = true } +core_test_support = { path = "../../../core/tests/common" } +shlex = { workspace = true } diff --git a/codex-rs/mcp-server/tests/common/lib.rs b/codex-rs/mcp-server/tests/common/lib.rs index d088b184ea..d8d6b67384 100644 --- a/codex-rs/mcp-server/tests/common/lib.rs +++ b/codex-rs/mcp-server/tests/common/lib.rs @@ -2,12 +2,13 @@ mod mcp_process; mod mock_model_server; mod responses; +pub use core_test_support::format_with_current_shell; pub use mcp_process::McpProcess; use mcp_types::JSONRPCResponse; pub use mock_model_server::create_mock_chat_completions_server; pub use responses::create_apply_patch_sse_response; pub use responses::create_final_assistant_message_sse_response; -pub use responses::create_shell_sse_response; +pub use responses::create_shell_command_sse_response; use serde::de::DeserializeOwned; pub fn to_response(response: JSONRPCResponse) -> anyhow::Result { diff --git a/codex-rs/mcp-server/tests/common/responses.rs b/codex-rs/mcp-server/tests/common/responses.rs index 9a827fb986..0a9183c043 100644 --- a/codex-rs/mcp-server/tests/common/responses.rs +++ b/codex-rs/mcp-server/tests/common/responses.rs @@ -1,17 +1,18 @@ use serde_json::json; use std::path::Path; -pub fn create_shell_sse_response( +pub fn create_shell_command_sse_response( command: Vec, workdir: Option<&Path>, timeout_ms: Option, call_id: &str, ) -> anyhow::Result { - // The `arguments`` for the `shell` tool is a serialized JSON object. + // The `arguments` for the `shell_command` tool is a serialized JSON object. + let command_str = shlex::try_join(command.iter().map(String::as_str))?; let tool_call_arguments = serde_json::to_string(&json!({ - "command": command, + "command": command_str, "workdir": workdir.map(|w| w.to_string_lossy()), - "timeout": timeout_ms + "timeout_ms": timeout_ms }))?; let tool_call = json!({ "choices": [ @@ -21,7 +22,7 @@ pub fn create_shell_sse_response( { "id": call_id, "function": { - "name": "shell", + "name": "shell_command", "arguments": tool_call_arguments } } @@ -62,10 +63,10 @@ pub fn create_apply_patch_sse_response( patch_content: &str, call_id: &str, ) -> anyhow::Result { - // Use shell command to call apply_patch with heredoc format - let shell_command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF"); + // Use shell_command to call apply_patch with heredoc format + let command = format!("apply_patch <<'EOF'\n{patch_content}\nEOF"); let tool_call_arguments = serde_json::to_string(&json!({ - "command": ["bash", "-lc", shell_command] + "command": command }))?; let tool_call = json!({ @@ -76,7 +77,7 @@ pub fn create_apply_patch_sse_response( { "id": call_id, "function": { - "name": "shell", + "name": "shell_command", "arguments": tool_call_arguments } } diff --git a/codex-rs/mcp-server/tests/suite/codex_tool.rs b/codex-rs/mcp-server/tests/suite/codex_tool.rs index ae0c23f1d8..f65495c473 100644 --- a/codex-rs/mcp-server/tests/suite/codex_tool.rs +++ b/codex-rs/mcp-server/tests/suite/codex_tool.rs @@ -30,7 +30,8 @@ use mcp_test_support::McpProcess; use mcp_test_support::create_apply_patch_sse_response; use mcp_test_support::create_final_assistant_message_sse_response; use mcp_test_support::create_mock_chat_completions_server; -use mcp_test_support::create_shell_sse_response; +use mcp_test_support::create_shell_command_sse_response; +use mcp_test_support::format_with_current_shell; // Allow ample time on slower CI or under load to avoid flakes. const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20); @@ -71,13 +72,16 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { "-c".to_string(), format!("import pathlib; pathlib.Path('{created_filename}').touch()"), ]; + let expected_shell_command = format_with_current_shell(&format!( + "python3 -c \"import pathlib; pathlib.Path('{created_filename}').touch()\"" + )); let McpHandle { process: mut mcp_process, server: _server, dir: _dir, } = create_mcp_process(vec![ - create_shell_sse_response( + create_shell_command_sse_response( shell_command.clone(), Some(workdir_for_shell_function_call.path()), Some(5_000), @@ -111,7 +115,7 @@ async fn shell_command_approval_triggers_elicitation() -> anyhow::Result<()> { )?; let expected_elicitation_request = create_expected_elicitation_request( elicitation_request_id.clone(), - shell_command.clone(), + expected_shell_command, workdir_for_shell_function_call.path(), codex_request_id.to_string(), params.codex_event_id.clone(), @@ -218,6 +222,12 @@ async fn test_patch_approval_triggers_elicitation() { } async fn patch_approval_triggers_elicitation() -> anyhow::Result<()> { + if cfg!(windows) { + // powershell apply_patch shell calls are not parsed into apply patch approvals + + return Ok(()); + } + let cwd = TempDir::new()?; let test_file = cwd.path().join("destination_file.txt"); std::fs::write(&test_file, "original content\n")?; diff --git a/codex-rs/otel/src/otel_event_manager.rs b/codex-rs/otel/src/otel_event_manager.rs index 5d9cbd4997..fde351cd69 100644 --- a/codex-rs/otel/src/otel_event_manager.rs +++ b/codex-rs/otel/src/otel_event_manager.rs @@ -88,7 +88,6 @@ impl OtelEventManager { reasoning_effort: Option, reasoning_summary: ReasoningSummary, context_window: Option, - max_output_tokens: Option, auto_compact_token_limit: Option, approval_policy: AskForApproval, sandbox_policy: SandboxPolicy, @@ -111,7 +110,6 @@ impl OtelEventManager { reasoning_effort = reasoning_effort.map(|e| e.to_string()), reasoning_summary = %reasoning_summary, context_window = context_window, - max_output_tokens = max_output_tokens, auto_compact_token_limit = auto_compact_token_limit, approval_policy = %approval_policy, sandbox_policy = %sandbox_policy, diff --git a/codex-rs/protocol/src/models.rs b/codex-rs/protocol/src/models.rs index 2e623eefef..b6bd92c6ea 100644 --- a/codex-rs/protocol/src/models.rs +++ b/codex-rs/protocol/src/models.rs @@ -230,8 +230,24 @@ pub struct LocalShellExecAction { #[serde(tag = "type", rename_all = "snake_case")] pub enum WebSearchAction { Search { - query: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + query: Option, }, + OpenPage { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + url: Option, + }, + FindInPage { + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + url: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + #[ts(optional)] + pattern: Option, + }, + #[serde(other)] Other, } @@ -634,6 +650,72 @@ mod tests { Ok(()) } + #[test] + fn roundtrips_web_search_call_actions() -> Result<()> { + let cases = vec![ + ( + r#"{ + "type": "web_search_call", + "status": "completed", + "action": { + "type": "search", + "query": "weather seattle" + } + }"#, + WebSearchAction::Search { + query: Some("weather seattle".into()), + }, + Some("completed".into()), + ), + ( + r#"{ + "type": "web_search_call", + "status": "open", + "action": { + "type": "open_page", + "url": "https://example.com" + } + }"#, + WebSearchAction::OpenPage { + url: Some("https://example.com".into()), + }, + Some("open".into()), + ), + ( + r#"{ + "type": "web_search_call", + "status": "in_progress", + "action": { + "type": "find_in_page", + "url": "https://example.com/docs", + "pattern": "installation" + } + }"#, + WebSearchAction::FindInPage { + url: Some("https://example.com/docs".into()), + pattern: Some("installation".into()), + }, + Some("in_progress".into()), + ), + ]; + + for (json_literal, expected_action, expected_status) in cases { + let parsed: ResponseItem = serde_json::from_str(json_literal)?; + let expected = ResponseItem::WebSearchCall { + id: None, + status: expected_status.clone(), + action: expected_action.clone(), + }; + assert_eq!(parsed, expected); + + let serialized = serde_json::to_value(&parsed)?; + let original_value: serde_json::Value = serde_json::from_str(json_literal)?; + assert_eq!(serialized, original_value); + } + + Ok(()) + } + #[test] fn deserialize_shell_tool_call_params() -> Result<()> { let json = r#"{ diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 5806e1a6f0..e0c2e4df3f 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -716,7 +716,7 @@ pub struct ExitedReviewModeEvent { pub struct ErrorEvent { pub message: String, #[serde(default)] - pub codex_error_code: Option, + pub codex_error_info: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] @@ -1395,7 +1395,7 @@ pub struct UndoCompletedEvent { pub struct StreamErrorEvent { pub message: String, #[serde(default)] - pub codex_error_code: Option, + pub codex_error_info: Option, } #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] diff --git a/codex-rs/scripts/create_github_release b/codex-rs/scripts/create_github_release index 47d6d3ce8c..e7b8972db5 100755 --- a/codex-rs/scripts/create_github_release +++ b/codex-rs/scripts/create_github_release @@ -298,8 +298,12 @@ def create_tag_ref(version: str, tag_sha: str) -> None: def determine_version(args: argparse.Namespace) -> str: latest_version = get_latest_release_version() - major, minor, patch = parse_semver(latest_version) - next_minor_version = format_version(major, minor + 1, patch) + # When determining the next version after the current release, + # we should always increment the minor version, but reset the + # patch to zero. In practice, `patch` should only be non-zero if + # --emergency-version-override was used. + major, minor, _patch = parse_semver(latest_version) + next_minor_version = format_version(major, minor + 1, 0) if args.publish_release: return next_minor_version diff --git a/codex-rs/tui/src/bottom_pane/list_selection_view.rs b/codex-rs/tui/src/bottom_pane/list_selection_view.rs index 88fbaf5b53..d294a47265 100644 --- a/codex-rs/tui/src/bottom_pane/list_selection_view.rs +++ b/codex-rs/tui/src/bottom_pane/list_selection_view.rs @@ -52,6 +52,7 @@ pub(crate) struct SelectionViewParams { pub is_searchable: bool, pub search_placeholder: Option, pub header: Box, + pub initial_selected_idx: Option, } impl Default for SelectionViewParams { @@ -64,6 +65,7 @@ impl Default for SelectionViewParams { is_searchable: false, search_placeholder: None, header: Box::new(()), + initial_selected_idx: None, } } } @@ -80,6 +82,7 @@ pub(crate) struct ListSelectionView { filtered_indices: Vec, last_selected_actual_idx: Option, header: Box, + initial_selected_idx: Option, } impl ListSelectionView { @@ -110,6 +113,7 @@ impl ListSelectionView { filtered_indices: Vec::new(), last_selected_actual_idx: None, header, + initial_selected_idx: params.initial_selected_idx, }; s.apply_filter(); s @@ -132,7 +136,8 @@ impl ListSelectionView { (!self.is_searchable) .then(|| self.items.iter().position(|item| item.is_current)) .flatten() - }); + }) + .or_else(|| self.initial_selected_idx.take()); if self.is_searchable && !self.search_query.is_empty() { let query_lower = self.search_query.to_lowercase(); diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 4144ad4c3e..470d582318 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -2120,6 +2120,14 @@ impl ChatWidget { } else { default_choice }; + let selection_choice = highlight_choice.or(default_choice); + let initial_selected_idx = choices + .iter() + .position(|choice| choice.stored == selection_choice) + .or_else(|| { + selection_choice + .and_then(|effort| choices.iter().position(|choice| choice.display == effort)) + }); let mut items: Vec = Vec::new(); for choice in choices.iter() { let effort = choice.display; @@ -2196,6 +2204,7 @@ impl ChatWidget { header: Box::new(header), footer_hint: Some(standard_popup_hint_line()), items, + initial_selected_idx, ..Default::default() }); } diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index 45a9a8df21..61e23fb3fc 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -2648,7 +2648,7 @@ fn stream_error_updates_status_indicator() { id: "sub-1".into(), msg: EventMsg::StreamError(StreamErrorEvent { message: msg.to_string(), - codex_error_code: Some(CodexErrorInfo::Other), + codex_error_info: Some(CodexErrorInfo::Other), }), }); diff --git a/codex-rs/windows-sandbox-rs/src/allow.rs b/codex-rs/windows-sandbox-rs/src/allow.rs index de62159994..7a879df4ac 100644 --- a/codex-rs/windows-sandbox-rs/src/allow.rs +++ b/codex-rs/windows-sandbox-rs/src/allow.rs @@ -18,6 +18,13 @@ pub fn compute_allow_paths( allow.push(p); } }; + let include_tmp_env_vars = matches!( + policy, + SandboxPolicy::WorkspaceWrite { + exclude_tmpdir_env_var: false, + .. + } + ); if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) { add_path(command_cwd.to_path_buf()); @@ -33,7 +40,7 @@ pub fn compute_allow_paths( } } } - if !matches!(policy, SandboxPolicy::ReadOnly) { + if include_tmp_env_vars { for key in ["TEMP", "TMP"] { if let Some(v) = env_map.get(key) { let abs = PathBuf::from(v); @@ -80,4 +87,32 @@ mod tests { "additional writable root should be allowed" ); } + + #[test] + fn excludes_tmp_env_vars_when_requested() { + let command_cwd = PathBuf::from(r"C:\Workspace"); + let temp_dir = PathBuf::from(r"C:\TempDir"); + let _ = fs::create_dir_all(&command_cwd); + let _ = fs::create_dir_all(&temp_dir); + + let policy = SandboxPolicy::WorkspaceWrite { + writable_roots: vec![], + network_access: false, + exclude_tmpdir_env_var: true, + exclude_slash_tmp: false, + }; + let mut env_map = HashMap::new(); + env_map.insert("TEMP".into(), temp_dir.to_string_lossy().to_string()); + + let allow = compute_allow_paths(&policy, &command_cwd, &command_cwd, &env_map); + + assert!( + allow.iter().any(|p| p == &command_cwd), + "command cwd should be allowed" + ); + assert!( + !allow.iter().any(|p| p == &temp_dir), + "TEMP should be excluded when exclude_tmpdir_env_var is true" + ); + } } diff --git a/codex-rs/windows-sandbox-rs/src/lib.rs b/codex-rs/windows-sandbox-rs/src/lib.rs index 1720294f2e..e2277daade 100644 --- a/codex-rs/windows-sandbox-rs/src/lib.rs +++ b/codex-rs/windows-sandbox-rs/src/lib.rs @@ -71,6 +71,10 @@ mod windows_impl { type PipeHandles = ((HANDLE, HANDLE), (HANDLE, HANDLE), (HANDLE, HANDLE)); + fn should_apply_network_block(policy: &SandboxPolicy) -> bool { + !policy.has_full_network_access() + } + fn ensure_dir(p: &Path) -> Result<()> { if let Some(d) = p.parent() { std::fs::create_dir_all(d)?; @@ -214,9 +218,12 @@ mod windows_impl { timeout_ms: Option, ) -> Result { let policy = parse_policy(policy_json_or_preset)?; + let apply_network_block = should_apply_network_block(&policy); normalize_null_device_env(&mut env_map); ensure_non_interactive_pager(&mut env_map); - apply_no_network_to_env(&mut env_map)?; + if apply_network_block { + apply_no_network_to_env(&mut env_map)?; + } ensure_codex_home_exists(codex_home)?; let current_dir = cwd.to_path_buf(); @@ -447,6 +454,36 @@ mod windows_impl { timed_out, }) } + + #[cfg(test)] + mod tests { + use super::should_apply_network_block; + use crate::policy::SandboxPolicy; + + fn workspace_policy(network_access: bool) -> SandboxPolicy { + SandboxPolicy::WorkspaceWrite { + writable_roots: Vec::new(), + network_access, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + } + } + + #[test] + fn applies_network_block_when_access_is_disabled() { + assert!(should_apply_network_block(&workspace_policy(false))); + } + + #[test] + fn skips_network_block_when_access_is_allowed() { + assert!(!should_apply_network_block(&workspace_policy(true))); + } + + #[test] + fn applies_network_block_for_read_only() { + assert!(should_apply_network_block(&SandboxPolicy::ReadOnly)); + } + } } #[cfg(not(target_os = "windows"))] diff --git a/docs/config.md b/docs/config.md index cb756c6bf6..d14f7482f2 100644 --- a/docs/config.md +++ b/docs/config.md @@ -247,12 +247,6 @@ The size of the context window for the model, in tokens. In general, Codex knows the context window for the most common OpenAI models, but if you are using a new model with an old version of the Codex CLI, then you can use `model_context_window` to tell Codex what value to use to determine how much context is left during a conversation. -### model_max_output_tokens - -This is analogous to `model_context_window`, but for the maximum number of output tokens for the model. - -> See also [`codex exec`](./exec.md) to see how these model settings influence non-interactive runs. - ### oss_provider Specifies the default OSS provider to use when running Codex. This is used when the `--oss` flag is provided without a specific provider. @@ -945,7 +939,6 @@ Valid values: | `model` | string | Model to use (e.g., `gpt-5.1-codex-max`). | | `model_provider` | string | Provider id from `model_providers` (default: `openai`). | | `model_context_window` | number | Context window tokens. | -| `model_max_output_tokens` | number | Max output tokens. | | `tool_output_token_limit` | number | Token budget for stored function/tool outputs in history (default: 2,560 tokens). | | `approval_policy` | `untrusted` \| `on-failure` \| `on-request` \| `never` | When to prompt for approval. | | `sandbox_mode` | `read-only` \| `workspace-write` \| `danger-full-access` | OS sandbox policy. | diff --git a/docs/example-config.md b/docs/example-config.md index f9d7955434..117d0cb2a2 100644 --- a/docs/example-config.md +++ b/docs/example-config.md @@ -30,7 +30,6 @@ model_provider = "openai" # Optional manual model metadata. When unset, Codex auto-detects from model. # Uncomment to force values. # model_context_window = 128000 # tokens; default: auto for model -# model_max_output_tokens = 8192 # tokens; default: auto for model # model_auto_compact_token_limit = 0 # disable/override auto; default: model family specific # tool_output_token_limit = 10000 # tokens stored per tool output; default: 10000 for gpt-5.1-codex-max 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", + }, +});